mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
3861b6d043
commit
753de070a0
8 files changed
+462
-84
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 ▸ <style> from the menu adds the style and opens the dialog on it.
|
||||
if crate::panels::layer_styles_dialog::route(app, id, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user