M3.10: Layer Settings opens Solid Settings with "Affect all layers that use this solid"; settings dialogs register every control

- 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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-04 23:22:09 +07:00
1 parent 3861b6d043
commit 753de070a0
8 files changed
+462 -84

No files matched your search

+68 -25
View File
@@ -49,11 +49,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
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<Value>
Ok(json!({"layer": id.0}))
}
/// `pixelAspect` (a positive number), when given.
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
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<Value> {
new_solid_like(s, p, false)
}
@@ -1044,6 +1053,11 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
_ => {}
}
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<CommandSpec> {
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<CommandSpec> {
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),
+50
View File
@@ -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"));
}
+4
View File
@@ -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, &params)? {
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, &params)? {
return Ok(Value::Null);
}
// Layer ▸ Layer Styles ▸ <style> from the menu adds the style and opens the dialog on it.
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);
+56 -27
View File
@@ -1,10 +1,12 @@
//! Composition Settings dialog (also New Composition), laid out like After Effects': name, then
//! Basic / Advanced / 3D Renderer tabs, Preview checkbox, Cancel / OK.
//! Basic / Advanced / 3D Renderer tabs, Cancel / OK. Every control registers an automation id
//! (`dialog.comp.<field>`).
use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode};
use egui::{Color32, vec2};
use serde_json::json;
use crate::automation::Registry;
use crate::theme::Tokens;
/// AE's composition presets: (name, width, height, pixel aspect, fps). "Custom" is implicit.
@@ -144,7 +146,7 @@ fn ae_timecode(smpte: &str) -> String {
format!("{}{rest}", if neg { "-" } else { "" })
}
fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, rate: FrameRate, min_frames: i64) {
fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, rate: FrameRate, min_frames: i64) -> egui::Response {
let df = rate.supports_drop_frame();
// Durations are whole frames in AE: 10 s at 29.97 fps is 0;00;10;00, not 9;29.
let shown = ae_timecode(&format_timecode_frames((*secs * rate.as_f64()).round() as i64, rate, df));
@@ -161,19 +163,23 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, r
}
let base = format!("Base {}{}", rate.timecode_base(), if df { "drop" } else { "" });
ui.label(egui::RichText::new(format!("is {shown} {base}")).color(Color32::GRAY));
r
}
/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel.
pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_json::Value> {
pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registry) -> Option<serde_json::Value> {
let mut out = None;
ui.horizontal(|ui| {
ui.label("Composition Name:");
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
auto.add("dialog.comp.name", r.rect, "Composition Name");
});
ui.add_space(8.0);
ui.horizontal(|ui| {
for (tab, label) in [(Tab::Basic, "Basic"), (Tab::Advanced, "Advanced"), (Tab::Renderer, "3D Renderer")] {
if ui.selectable_label(d.tab == tab, label).clicked() {
for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] {
let r = ui.selectable_label(d.tab == tab, label);
auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label);
if r.clicked() {
d.tab = tab;
}
}
@@ -189,20 +195,23 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
.find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3)
.map(|p| p.0)
.unwrap_or("Custom");
egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
for (name, w, h, par, f) in PRESETS {
if ui.selectable_label(*name == current, *name).clicked() {
(d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f);
}
}
});
auto.add("dialog.comp.preset", r.response.rect, "Preset");
ui.end_row();
ui.label("Width:");
ui.horizontal(|ui| {
let (ow, oh) = (d.width, d.height);
ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"));
auto.add("dialog.comp.width", r.rect, "Width");
let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64));
ui.checkbox(&mut d.lock_aspect, lbl);
let r = ui.checkbox(&mut d.lock_aspect, lbl);
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
if d.lock_aspect && ow != d.width && ow > 0 {
d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32;
}
@@ -210,7 +219,8 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.end_row();
ui.label("Height:");
let (ow, oh) = (d.width, d.height);
ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
auto.add("dialog.comp.height", r.rect, "Height");
if d.lock_aspect && oh != d.height && oh > 0 {
d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32;
}
@@ -222,13 +232,14 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
.find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{:.2}", d.pixel_aspect));
egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
let r = egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
for (name, v) in PIXEL_ASPECTS {
if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() {
d.pixel_aspect = *v;
}
}
});
auto.add("dialog.comp.pixelAspect", r.response.rect, "Pixel Aspect Ratio");
ui.label(
egui::RichText::new(format!("Frame Aspect Ratio: {}", aspect_label(d.width as f64 * d.pixel_aspect, d.height as f64)))
.color(Color32::GRAY),
@@ -237,14 +248,17 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.end_row();
ui.label("Frame Rate:");
ui.horizontal(|ui| {
let txt = format!("{:.3}", d.fps).trim_end_matches('0').trim_end_matches('.').to_string();
egui::ComboBox::from_id_salt("comp-fps").width(80.0).selected_text(txt).show_ui(ui, |ui| {
// Any rate can be typed; the list holds the common ones.
let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3));
auto.add("dialog.comp.frameRate", r.rect, "Frame Rate");
let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| {
for f in FRAME_RATES {
if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() {
d.fps = *f;
}
}
});
auto.add("dialog.comp.frameRates", r.response.rect, "Frame Rate presets");
ui.label("frames per second");
if rate.supports_drop_frame() {
ui.label(egui::RichText::new("Drop Frame").color(Color32::GRAY));
@@ -256,14 +270,17 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label(egui::RichText::new(format!("{} x {}, {mb:.1}MB per 8bpc frame", d.width, d.height)).color(Color32::GRAY));
ui.end_row();
ui.label("Start Timecode:");
ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)).inner;
auto.add("dialog.comp.startTimecode", r.rect, "Start Timecode");
ui.end_row();
ui.label("Duration:");
ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1)).inner;
auto.add("dialog.comp.duration", r.rect, "Duration");
ui.end_row();
ui.label("Background Color:");
ui.horizontal(|ui| {
ui.color_edit_button_rgb(&mut d.bg);
let r = crate::widgets::srgb_color_button(ui, &mut d.bg);
auto.add("dialog.comp.background", r.rect, "Background Color");
ui.label(color_name(d.bg));
});
ui.end_row();
@@ -276,7 +293,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
for i in 0..9u8 {
let on = d.anchor == i;
let b = egui::Button::new(if on { "●" } else { "" }).min_size(vec2(22.0, 22.0)).fill(if on { t.accent } else { t.field_bg });
if ui.add(b).clicked() {
let r = ui.add(b);
auto.add(&format!("dialog.comp.anchor.{i}"), r.rect, "Anchor");
if r.clicked() {
d.anchor = i;
}
if i % 3 == 2 {
@@ -288,36 +307,46 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label(egui::RichText::new("Motion Blur").strong());
ui.end_row();
ui.label("Shutter Angle:");
ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"));
let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"));
auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle");
ui.end_row();
ui.label("Shutter Phase:");
ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"));
let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"));
auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase");
ui.end_row();
ui.label("Samples Per Frame:");
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
auto.add("dialog.comp.samples", r.rect, "Samples Per Frame");
ui.end_row();
ui.label("Adaptive Sample Limit:");
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit");
ui.end_row();
});
}
Tab::Renderer => {
ui.horizontal(|ui| {
ui.label("Renderer:");
egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| {
ui.selectable_value(&mut d.advanced_3d, false, "Classic 3D");
ui.selectable_value(&mut d.advanced_3d, true, "Advanced 3D");
});
let r =
egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| {
ui.selectable_value(&mut d.advanced_3d, false, "Classic 3D");
ui.selectable_value(&mut d.advanced_3d, true, "Advanced 3D");
});
auto.add("dialog.comp.renderer", r.response.rect, "Renderer");
});
}
}
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.comp.ok", r.rect, "OK");
if r.clicked() {
out = Some(params(d));
}
if ui.button("Cancel").clicked() {
let r = ui.button("Cancel");
auto.add("dialog.comp.cancel", r.rect, "Cancel");
if r.clicked() {
out = Some(serde_json::Value::Null);
}
});
+255 -32
View File
@@ -1,8 +1,9 @@
//! Modal dialogs: About (with community links), New Composition / Composition Settings, Solid
//! Settings and the command palette (Camera/Light Settings live in `dialogs_3d`).
use effectcraft_engine::project::{ItemId, ItemKind, LayerId, LayerSource};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use serde_json::{Value, json};
pub use super::forms::{Field, form, info, open_form};
use crate::icons::{self, Icon};
@@ -14,10 +15,20 @@ pub use super::comp_settings::CompDraft;
#[derive(Clone, Debug, Default)]
pub struct DialogState {
pub comp: CompDraft,
/// The settings of the last composition made with New Composition (the next one starts
/// from them, as in After Effects).
pub last_new_comp: Option<CompDraft>,
pub editing_existing: bool,
pub solid_name: String,
pub solid_color: [f32; 3],
pub solid_size: [u32; 2],
pub solid_pixel_aspect: f64,
/// Solid Settings edits this layer's solid (Layer ▸ Layer Settings) instead of making one.
pub solid_layer: Option<u64>,
/// Other layers use the edited solid too.
pub solid_shared: bool,
/// "Affect all layers that use this solid" (off: the layer gets its own copy).
pub solid_affect_all: bool,
pub palette_query: String,
pub palette_sel: usize,
/// Settings dialog page id (`general`, `appearance`…).
@@ -50,7 +61,8 @@ pub struct DialogState {
pub fn open_new_comp(app: &mut EffectcraftApp) {
let n = app.session.project.comps().count() + 1;
app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), ..Default::default() };
let last = app.dialog_state.last_new_comp.clone().unwrap_or_default();
app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, ..last };
app.dialog_state.editing_existing = false;
app.dialog = Some(Dialog::NewComp);
}
@@ -82,15 +94,56 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
let c = app.session.active_comp().ok_or("no composition is open")?;
let n = app.session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).count() + 1;
app.dialog_state.solid_name = format!("Solid {n}");
app.dialog_state.solid_size = [c.width, c.height];
if app.dialog_state.solid_color == [0.0; 3] {
app.dialog_state.solid_color = [0.2, 0.45, 0.85];
let d = &mut app.dialog_state;
d.solid_name = format!("Solid {n}");
d.solid_size = [c.width, c.height];
d.solid_pixel_aspect = c.pixel_aspect;
d.solid_layer = None;
if d.solid_color == [0.0; 3] {
d.solid_color = [0.2, 0.45, 0.85];
}
app.dialog = Some(Dialog::SolidSettings);
Ok(())
}
/// Layer ▸ Layer Settings on a solid or adjustment layer: Solid Settings on its solid.
fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Result<(), String> {
let s = &app.session;
let it = s.project.item(item).ok_or("the layer's solid is missing")?;
let ItemKind::Solid(so) = &it.kind else { return Err("not a solid".into()) };
let users = s.project.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == LayerSource::Solid { item }).count();
let name = s.active_comp().and_then(|c| c.layer(LayerId(layer))).map(|l| l.name.clone()).unwrap_or_else(|| it.name.clone());
let d = &mut app.dialog_state;
(d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect);
(d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false);
app.dialog = Some(Dialog::SolidSettings);
Ok(())
}
/// Layer ▸ Layer Settings without parameters: Solid Settings for solids and adjustment layers,
/// the layer's name for nulls (cameras and lights: `dialogs_3d::route`). Returns true when a
/// dialog opened.
pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.settings" || !params.as_object().is_none_or(|m| m.keys().all(|k| k == "layer")) {
return Ok(false);
}
let s = &app.session;
let Some(l) =
params.get("layer").and_then(Value::as_u64).or_else(|| s.state.selected_layers.first().map(|l| l.0)).and_then(|l| s.active_comp()?.layer(LayerId(l)))
else {
return Ok(false);
};
let (lid, name) = (l.id.0, l.name.clone());
match l.source {
LayerSource::Solid { item } => open_solid_settings(app, lid, item).map(|_| true),
LayerSource::Null => {
form(app, "Null Settings", "layer.settings", json!({"layer": lid}), vec![Field::text("name", "Name", &name)]);
Ok(true)
}
_ => Ok(false),
}
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
// Dim the app.
let screen = ctx.content_rect();
@@ -213,7 +266,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
let mut d = app.dialog_state.comp.clone();
let mut result = None;
modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| {
result = super::comp_settings::show(ui, &mut d, t);
result = super::comp_settings::show(ui, &mut d, t, &mut app.auto);
});
if result.is_none() && ui_enter(ctx) {
result = Some(super::comp_settings::params(&d));
@@ -223,6 +276,9 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
Some(serde_json::Value::Null) => app.dialog = None,
Some(params) => {
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
if !existing && r.is_ok() {
app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone());
}
if let Err(e) = r {
app.ui.status = e.to_string();
}
@@ -236,19 +292,22 @@ pub(crate) fn ui_enter(ctx: &egui::Context) -> bool {
!ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter))
}
/// Solid Settings: a new solid (Layer ▸ New ▸ Solid…) or the layer's solid (Layer Settings).
fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
let mut ok = false;
let mut name = app.dialog_state.solid_name.clone();
let mut col = app.dialog_state.solid_color;
let mut size = app.dialog_state.solid_size;
let (mut close, mut ok) = (false, false);
let ds = &app.dialog_state;
let (mut name, mut color, mut size, mut par, mut affect_all) =
(ds.solid_name.clone(), ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all);
let (editing, shared) = (ds.solid_layer, ds.solid_shared);
let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
let lock_id = egui::Id::new("solid-lock-aspect");
let mut lock = ctx.data(|d| d.get_temp::<bool>(lock_id)).unwrap_or(true);
modal(ctx, "Solid Settings", vec2(460.0, 360.0), t, |ui| {
let mut lock = ctx.data(|m| m.get_temp::<bool>(lock_id)).unwrap_or(true);
let auto = &mut app.auto;
modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
auto.add("dialog.solid.name", r.rect, "Name");
});
ui.add_space(8.0);
ui.label(egui::RichText::new("Size").strong());
@@ -256,12 +315,15 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let (ow, oh) = (size[0], size[1]);
ui.label("Width:");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"));
ui.checkbox(&mut lock, format!("Lock Aspect Ratio to {}", super::comp_settings::aspect_label(ow as f64, oh as f64)));
let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"));
auto.add("dialog.solid.width", r.rect, "Width");
let r = ui.checkbox(&mut lock, format!("Lock Aspect Ratio to {}", super::comp_settings::aspect_label(ow as f64, oh as f64)));
auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio");
});
ui.end_row();
ui.label("Height:");
ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"));
let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"));
auto.add("dialog.solid.height", r.rect, "Height");
ui.end_row();
if lock && ow > 0 && oh > 0 {
if size[0] != ow {
@@ -270,6 +332,21 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
size[0] = ((size[1] as f64) * ow as f64 / oh as f64).round().max(1.0) as u32;
}
}
ui.label("Pixel Aspect Ratio:");
let cur = super::comp_settings::PIXEL_ASPECTS
.iter()
.find(|(_, v)| (v - par).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{par:.2}"));
let r = egui::ComboBox::from_id_salt("solid-par").width(240.0).selected_text(cur).show_ui(ui, |ui| {
for (label, v) in super::comp_settings::PIXEL_ASPECTS {
if ui.selectable_label((v - par).abs() < 1e-3, *label).clicked() {
par = *v;
}
}
});
auto.add("dialog.solid.pixelAspect", r.response.rect, "Pixel Aspect Ratio");
ui.end_row();
});
let pct = |a: u32, b: u32| 100.0 * a as f64 / b.max(1) as f64;
ui.label(
@@ -277,35 +354,49 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
"Width: {:.1}% of comp\nHeight: {:.1}% of comp\nFrame Aspect Ratio: {}",
pct(size[0], comp_size.0),
pct(size[1], comp_size.1),
super::comp_settings::aspect_label(size[0] as f64, size[1] as f64)
super::comp_settings::aspect_label(size[0] as f64 * par, size[1] as f64)
))
.color(Color32::GRAY),
);
ui.add_space(4.0);
if ui.button("Make Comp Size").clicked() {
let r = ui.button("Make Comp Size");
auto.add("dialog.solid.makeCompSize", r.rect, "Make Comp Size");
if r.clicked() {
size = [comp_size.0, comp_size.1];
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Color:");
ui.color_edit_button_rgb(&mut col);
let r = crate::widgets::srgb_color_button(ui, &mut color);
auto.add("dialog.solid.color", r.rect, "Color");
});
if editing.is_some() {
ui.add_space(8.0);
let r = ui
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, "Affect all layers that use this solid"))
.on_disabled_hover_text("Only this layer uses this solid.");
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
if shared && !affect_all {
ui.label(egui::RichText::new("This change will create a new solid for this layer.").color(t.text_dim).size(11.0));
}
}
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
ok = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.solid.ok", r.rect, "OK");
ok = r.clicked();
let r = ui.button("Cancel");
auto.add("dialog.solid.cancel", r.rect, "Cancel");
close = r.clicked();
});
});
ctx.data_mut(|d| d.insert_temp(lock_id, lock));
app.dialog_state.solid_name = name.clone();
app.dialog_state.solid_color = col;
app.dialog_state.solid_size = size;
ctx.data_mut(|m| m.insert_temp(lock_id, lock));
let ds = &mut app.dialog_state;
(ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all);
if ok || ui_enter(ctx) {
let _ = app.session.execute("layer.newSolid", json!({"name": name, "color": [col[0], col[1], col[2]], "width": size[0], "height": size[1]}));
if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
@@ -313,6 +404,23 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
}
/// The command Solid Settings' OK runs.
fn solid_command(d: &DialogState) -> &'static str {
if d.solid_layer.is_some() { "layer.settings" } else { "layer.newSolid" }
}
/// Its parameters.
fn solid_params(d: &DialogState) -> Value {
let mut p = json!({
"name": d.solid_name, "color": d.solid_color, "width": d.solid_size[0], "height": d.solid_size[1], "pixelAspect": d.solid_pixel_aspect,
});
if let Some(l) = d.solid_layer {
p["layer"] = json!(l);
p["affectAll"] = json!(d.solid_affect_all || !d.solid_shared);
}
p
}
/// ⌘⇧P: fuzzy search over every command (engine + UI) and effect.
fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
@@ -388,3 +496,118 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
crate::theme::install(&ctx, &app.tokens);
app.session.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360})).unwrap();
(app, ctx)
}
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(app, ui.ctx());
});
out.textures_delta.clear();
}
fn solid_item(app: &EffectcraftApp, l: u64) -> ItemId {
match app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().source {
LayerSource::Solid { item } => item,
_ => panic!("not a solid"),
}
}
#[test]
fn layer_settings_opens_solid_settings_on_the_layers_solid() {
let (mut app, ctx) = app();
let a =
app.session.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!(app.dialog, Some(Dialog::SolidSettings));
let d = &app.dialog_state;
assert_eq!((d.solid_layer, d.solid_shared, d.solid_size, d.solid_name.as_str()), (Some(a), false, [200, 100], "Red"));
frame(&mut app, &ctx);
for id in ["name", "width", "lockAspect", "height", "pixelAspect", "makeCompSize", "color", "affectAll", "ok", "cancel"] {
assert!(app.auto.find(&format!("dialog.solid.{id}")).is_some(), "dialog.solid.{id}");
}
// Only this layer uses the solid: OK changes it in place.
app.dialog_state.solid_color = [0.0, 1.0, 0.0];
assert_eq!(solid_command(&app.dialog_state), "layer.settings");
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
let item = solid_item(&app, a);
assert!(matches!(&app.session.project.item(item).unwrap().kind, ItemKind::Solid(so) if so.color == [0.0, 1.0, 0.0]));
// Shared with a duplicate: "Affect all" starts off, and OK gives the layer its own solid.
let b = app.session.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({"layer": b})).unwrap();
assert!(app.dialog_state.solid_shared && !app.dialog_state.solid_affect_all);
app.dialog_state.solid_size = [64, 64];
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
assert_ne!(solid_item(&app, a), solid_item(&app, b));
assert!(matches!(&app.session.project.item(solid_item(&app, a)).unwrap().kind, ItemKind::Solid(so) if so.width == 200));
// A null asks for its name; New Solid makes a solid with the comp's pixel aspect.
app.session.execute("layer.newNull", json!({})).unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!((app.dialog, app.dialog_state.form.command.as_str()), (Some(Dialog::Form), "layer.settings"));
open_new_solid(&mut app).unwrap();
assert_eq!((solid_command(&app.dialog_state), app.dialog_state.solid_pixel_aspect), ("layer.newSolid", 1.0));
}
#[test]
fn composition_settings_register_every_control_and_new_comp_remembers() {
let (mut app, ctx) = app();
open_new_comp(&mut app);
for tab in [super::super::comp_settings::Tab::Basic, super::super::comp_settings::Tab::Advanced, super::super::comp_settings::Tab::Renderer] {
app.dialog_state.comp.tab = tab;
frame(&mut app, &ctx);
}
let ids = [
"name",
"tab.basic",
"tab.advanced",
"tab.renderer",
"ok",
"cancel",
"renderer", // 3D Renderer tab (last shown)
];
for id in ids {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Advanced;
frame(&mut app, &ctx);
for id in ["anchor.4", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Basic;
frame(&mut app, &ctx);
for id in ["preset", "width", "lockAspect", "height", "pixelAspect", "frameRate", "frameRates", "startTimecode", "duration", "background"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
// A typed rate that isn't in the list, then OK (Enter): the next New Composition starts
// from these settings with a new name.
(app.dialog_state.comp.width, app.dialog_state.comp.height, app.dialog_state.comp.fps) = (1000, 500, 18.0);
let p = super::super::comp_settings::params(&app.dialog_state.comp);
assert_eq!(p["frameRate"], json!(18.0));
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(&mut app, ui.ctx(), &t, false);
});
out.textures_delta.clear();
assert!(app.dialog.is_none());
let c = app.session.active_comp().unwrap();
assert_eq!((c.width, c.height, c.frame_rate.as_f64()), (1000, 500, 18.0));
open_new_comp(&mut app);
let n = &app.dialog_state.comp;
assert_eq!((n.width, n.height, n.fps, n.name.as_str()), (1000, 500, 18.0, "Comp 3"));
}
}
+12
View File
@@ -83,6 +83,18 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) ->
resp
}
/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the
/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter).
/// The value is only rewritten when the user picks a colour.
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let r = ui.color_edit_button_srgb(&mut u);
if r.changed() {
*c = u.map(|v| v as f32 / 255.0);
}
r
}
/// A twirl-down triangle; returns clicked.
pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response {
let resp = ui.interact(rect, id, Sense::click());
+5
View File
@@ -111,6 +111,11 @@ build real projects through these interfaces; they are worked examples of everyt
* **Name things when you create them**: `layer.newText`, `layer.newShape`, `layer.newSolid`,
`layer.newNull`, `layer.newCamera` and `layer.newLight` take `name`, so later calls can say
`"layer": "Title"`. A reference that matches no layer is an error, never a silent no-op.
* **Solid Settings**: `layer.settings {layer, name?, color?, width?, height?, pixelAspect?,
affectAll?}` edits a solid or adjustment layer's solid (and renames the layer and the solid).
A solid shared by several layers (duplicates share theirs) changes for all of them unless
`affectAll: false`, which gives this layer its own copy; the reply names it (`{layer, solid}`).
Cameras and lights take their own settings through the same command.
* **Use the paths replies give you**: `effect.apply` returns `{effects: [uid], paths:
["effects/#2"]}`; `layer.addShapeItem {kind, group?: uid|path}` returns `{uid, path}` (e.g.
`contents/group/contents/gfill`), ready for `set_property`. `@uid` works as a whole path.
+12
View File
@@ -383,6 +383,18 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
## Update: M3.10 Layer Settings and settings dialogs
Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid
(it did nothing before): name, size, Pixel Aspect Ratio, colour and **Affect all layers that use
this solid**, which starts off as in After Effects, so a change to a solid other layers share
gives this layer a new solid of its own; nulls ask for their name, and text and shape layers
have no settings (the entry is disabled). New solids take a Pixel Aspect Ratio too. The
Composition Settings / New Composition and Solid Settings dialogs register an automation id
for every control (`dialog.comp.*`, `dialog.solid.*`), Composition Settings takes any typed
frame rate besides the list, New Composition starts from the settings of the last composition
made with it, and their colour buttons show the stored sRGB colour (they showed it lighter).
## Update: M3.9 unsaved changes
Closing a modified project asks first, as After Effects does: Quit and the window's close