Files
effectcraft/crates/ui-egui/src/panels/comp_settings.rs
T
Brandon Thomas 777b59a391 Merge pull request #935 from aniliashenko/comp-presets
Composition Settings: save and delete your own presets

# Conflicts:
#	crates/engine/src/commands/comp_more.rs
2026-10-12 04:21:04 -04:00

514 lines
23 KiB
Rust

//! Composition Settings dialog (also New Composition), laid out like After Effects': name, then
//! Basic / Advanced / 3D Renderer tabs, Cancel / OK. Every control registers an automation id
//! (`dialog.comp.<field>`).
use crate::i18n::{tr, tr_args};
use crate::icons::Icon;
use effectcraft_engine::prefs::CompPreset;
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.
pub const PRESETS: &[(&str, u32, u32, f64, f64)] = &[
("Social Media Portrait HD · 1080x1920 · 30 fps", 1080, 1920, 1.0, 30.0),
("Social Media Landscape HD · 1920x1080 · 30 fps", 1920, 1080, 1.0, 30.0),
("Social Media Square · 1080x1080 · 30 fps", 1080, 1080, 1.0, 30.0),
("Social Media Portrait · 1080x1350 · 30 fps", 1080, 1350, 1.0, 30.0),
("NTSC DV", 720, 480, 0.9091, 29.97),
("NTSC DV Widescreen", 720, 480, 1.2121, 29.97),
("NTSC D1", 720, 486, 0.9091, 29.97),
("NTSC D1 Widescreen", 720, 486, 1.2121, 29.97),
("NTSC D1 Square Pix", 720, 540, 1.0, 29.97),
("NTSC D1 Widescreen Square Pix", 872, 486, 1.0, 29.97),
("PAL D1/DV", 720, 576, 1.0940, 25.0),
("PAL D1/DV Widescreen", 720, 576, 1.4587, 25.0),
("PAL D1/DV Square Pix", 788, 576, 1.0, 25.0),
("PAL D1/DV Widescreen Square Pix", 1050, 576, 1.0, 25.0),
("HDV/HDTV 720 29.97", 1280, 720, 1.0, 29.97),
("HDV/HDTV 720 25", 1280, 720, 1.0, 25.0),
("DVCPRO HD 720 23.976", 960, 720, 1.3333, 23.976),
("DVCPRO HD 720 25", 960, 720, 1.3333, 25.0),
("DVCPRO HD 720 29.97", 960, 720, 1.3333, 29.97),
("HDTV 1080 24", 1920, 1080, 1.0, 24.0),
("HDTV 1080 25", 1920, 1080, 1.0, 25.0),
("HDTV 1080 29.97", 1920, 1080, 1.0, 29.97),
("HDV 1080 25", 1440, 1080, 1.3333, 25.0),
("HDV 1080 29.97", 1440, 1080, 1.3333, 29.97),
("DVCPRO HD 1080 25", 1440, 1080, 1.3333, 25.0),
("DVCPRO HD 1080 29.97", 1280, 1080, 1.5, 29.97),
("Cineon Half", 1828, 1332, 1.0, 24.0),
("Cineon Full", 3656, 2664, 1.0, 24.0),
("Film (2K)", 2048, 1556, 1.0, 24.0),
("Film (4K)", 4096, 3112, 1.0, 24.0),
("UHD 4K 23.976", 3840, 2160, 1.0, 23.976),
("UHD 4K 25", 3840, 2160, 1.0, 25.0),
("UHD 4K 29.97", 3840, 2160, 1.0, 29.97),
("UHD 8K 23.976", 7680, 4320, 1.0, 23.976),
("UHD 8K 25", 7680, 4320, 1.0, 25.0),
("UHD 8K 29.97", 7680, 4320, 1.0, 29.97),
];
/// AE's Pixel Aspect Ratio choices.
pub const PIXEL_ASPECTS: &[(&str, f64)] = &[
("Square Pixels", 1.0),
("D1/DV NTSC (0.91)", 0.9091),
("D1/DV NTSC Widescreen (1.21)", 1.2121),
("D1/DV PAL (1.09)", 1.0940),
("D1/DV PAL Widescreen (1.46)", 1.4587),
("Anamorphic 2:1 (2)", 2.0),
("HDV 1080/DVCPRO HD 720 (1.33)", 1.3333),
("DVCPRO HD 1080 (1.5)", 1.5),
];
const FRAME_RATES: &[f64] = &[8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0, 120.0];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Tab {
#[default]
Basic,
Advanced,
Renderer,
}
/// The dialog's working copy.
#[derive(Clone, Debug)]
pub struct CompDraft {
pub name: String,
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub fps: f64,
pub duration: f64,
pub start: f64,
pub bg: [f32; 3],
pub lock_aspect: bool,
pub anchor: u8,
pub shutter_angle: f64,
pub shutter_phase: f64,
pub samples: u32,
pub adaptive_limit: u32,
pub preserve_frame_rate: bool,
pub preserve_resolution: bool,
pub advanced_3d: bool,
pub tab: Tab,
/// Timecode text being edited (start, duration).
pub start_tc: Option<String>,
pub dur_tc: Option<String>,
/// The name being typed for Save Preset (the name row shows while `Some`).
pub preset_name: Option<String>,
}
impl Default for CompDraft {
fn default() -> Self {
CompDraft {
name: "Comp 1".into(),
width: 1920,
height: 1080,
pixel_aspect: 1.0,
fps: 29.97,
duration: 10.0,
start: 0.0,
bg: [0.0, 0.0, 0.0],
lock_aspect: true,
anchor: 4,
shutter_angle: 180.0,
shutter_phase: -90.0,
samples: 16,
adaptive_limit: 128,
preserve_frame_rate: false,
preserve_resolution: false,
advanced_3d: false,
tab: Tab::Basic,
start_tc: None,
dur_tc: None,
preset_name: None,
}
}
}
/// `16:9 (1.78)`: the nearest small whole-number ratio and its decimal.
pub fn aspect_label(w: f64, h: f64) -> String {
let r = w / h.max(1.0);
let best = (1..=32u32).map(|b| ((r * b as f64).round() as u32, b)).filter(|(a, _)| *a > 0).find(|(a, b)| (*a as f64 / *b as f64 - r).abs() < 0.006);
match best {
Some((a, b)) => format!("{a}:{b} ({r:.2})"),
None => format!("{r:.2}"),
}
}
/// AE's name for a background color when it is one of the obvious ones.
fn color_name(c: [f32; 3]) -> &'static str {
match c.map(|v| (v * 255.0).round() as u8) {
[0, 0, 0] => "Black",
[255, 255, 255] => "White",
_ => "",
}
}
/// AE prints the hour without a leading zero: `0;00;10;00`.
fn ae_timecode(smpte: &str) -> String {
let (neg, rest) = smpte.strip_prefix('-').map_or((false, smpte), |r| (true, r));
let rest = rest.strip_prefix('0').filter(|r| r.as_bytes().first().is_some_and(u8::is_ascii_digit)).unwrap_or(rest);
format!("{}{rest}", if neg { "-" } else { "" })
}
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));
let mut text = buf.clone().unwrap_or_else(|| shown.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0).font(Tokens::mono(12.0)));
if r.changed() {
*buf = Some(text.clone());
}
if r.lost_focus() {
if let Ok(f) = parse_timecode(&text, rate, df, 0) {
*secs = rate.tick_of(f.max(min_frames)).seconds();
}
*buf = None;
}
let base = format!("Base {}{}", rate.timecode_base(), if df { "drop" } else { "" });
ui.label(egui::RichText::new(format!("is {shown} {base}")).color(Color32::GRAY));
r
}
fn name_focus_id() -> egui::Id {
egui::Id::new("comp-preset-name-focus")
}
/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while
/// `d.preset_name` is `Some`): OK or Enter saves the draft's frame size, pixel aspect and frame
/// rate under the name, replacing a saved preset with that name; Cancel or Escape closes it.
pub fn choose_name(
ui: &mut egui::Ui,
d: &mut CompDraft,
t: &Tokens,
auto: &mut Registry,
custom: &[CompPreset],
cmds: &mut Vec<(&'static str, serde_json::Value)>,
) {
let Some(mut name) = d.preset_name.take() else { return };
let mut keep = true;
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(ui.available_width()));
auto.add("dialog.comp.presetName", r.rect, "Preset Name");
if ui.data_mut(|m| m.remove_temp::<bool>(name_focus_id())).unwrap_or(false) {
r.request_focus();
}
let trimmed = name.trim();
let builtin = PRESETS.iter().any(|p| p.0 == trimmed);
let exists = custom.iter().any(|q| q.name == trimmed);
let ok = !trimmed.is_empty() && !builtin && trimmed.chars().count() <= CompPreset::MAX_NAME;
let note = if builtin {
tr("A built-in preset has this name")
} else if exists {
tr("Replaces your preset with this name")
} else {
""
};
ui.add_space(4.0);
ui.label(egui::RichText::new(note).color(Color32::GRAY));
ui.add_space(8.0);
let mut save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add_enabled(ok, egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.comp.presetNameSave", b.rect, "OK");
save |= b.clicked();
let c = ui.button(tr("Cancel"));
auto.add("dialog.comp.presetNameCancel", c.rect, "Cancel");
cancel |= c.clicked();
});
if ok && save {
cmds.push(("comp.savePreset", json!({"name": trimmed, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps})));
keep = false;
}
if cancel {
keep = false;
}
if keep {
d.preset_name = Some(name);
}
}
/// Whether the draft's frame size, pixel aspect and frame rate are those of a preset.
fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool {
w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3
}
/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom`
/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`.
pub fn show(
ui: &mut egui::Ui,
d: &mut CompDraft,
t: &Tokens,
auto: &mut Registry,
custom: &[CompPreset],
cmds: &mut Vec<(&'static str, serde_json::Value)>,
) -> Option<serde_json::Value> {
let mut out = None;
ui.horizontal(|ui| {
ui.label(tr("Composition Name:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
auto.add("dialog.comp.name", r.rect, "Composition Name");
});
ui.add_space(8.0);
ui.horizontal(|ui| {
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;
}
}
});
ui.separator();
let rate = FrameRate::from_f64(d.fps);
match d.tab {
Tab::Basic => {
egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| {
ui.label(tr("Preset:"));
// The user's own presets win over a built-in one with the same values.
let own = custom.iter().find(|q| matches(d, q.width, q.height, q.pixel_aspect, q.frame_rate));
let current = own
.map(|q| q.name.as_str())
.or_else(|| PRESETS.iter().find(|(_, w, h, par, f)| matches(d, *w, *h, *par, *f)).map(|p| p.0))
.unwrap_or("Custom");
ui.horizontal(|ui| {
let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| {
// Your own presets first, then the built-in ones.
for q in custom {
if ui.selectable_label(q.name == current, &q.name).clicked() {
(d.width, d.height, d.pixel_aspect, d.fps) = (q.width, q.height, q.pixel_aspect, q.frame_rate);
}
}
if !custom.is_empty() {
ui.separator();
}
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");
let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save"))
.on_hover_text(tr("Save Preset…"));
auto.add("dialog.comp.presetSave", save_r, "Save Preset");
if save.clicked() {
d.preset_name = Some(own.map(|q| q.name.clone()).unwrap_or_default());
ui.data_mut(|m| m.insert_temp(name_focus_id(), true));
}
if let Some(q) = own {
let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete"))
.on_hover_text(tr("Delete Preset"));
auto.add("dialog.comp.presetDelete", del_r, "Delete Preset");
if del.clicked() {
cmds.push(("comp.deletePreset", json!({"name": q.name})));
}
}
});
ui.end_row();
ui.label(tr("Width:"));
ui.horizontal(|ui| {
let (ow, oh) = (d.width, d.height);
let r = ui.add(crate::widgets::drag_value(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));
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;
}
});
ui.end_row();
ui.label(tr("Height:"));
let (ow, oh) = (d.width, d.height);
let r = ui.add(crate::widgets::drag_value(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;
}
ui.end_row();
ui.label(tr("Pixel Aspect Ratio:"));
ui.horizontal(|ui| {
let cur = PIXEL_ASPECTS
.iter()
.find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{:.2}", d.pixel_aspect));
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(tr_args("Frame Aspect Ratio: {}", &[&(aspect_label(d.width as f64 * d.pixel_aspect, d.height as f64))]))
.color(Color32::GRAY),
);
});
ui.end_row();
ui.label(tr("Frame Rate:"));
ui.horizontal(|ui| {
// Any rate can be typed; the list holds the common ones.
let r = ui.add(crate::widgets::drag_value(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(tr("frames per second"));
if rate.supports_drop_frame() {
ui.label(egui::RichText::new(tr("Drop Frame")).color(Color32::GRAY));
}
});
ui.end_row();
ui.label("");
let mb = d.width as f64 * d.height as f64 * 4.0 / (1024.0 * 1024.0);
ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&d.width, &d.height, &format!("{mb:.1}")])).color(Color32::GRAY));
ui.end_row();
ui.label(tr("Start Timecode:"));
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(tr("Duration:"));
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(tr("Background Color:"));
ui.horizontal(|ui| {
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();
});
}
Tab::Advanced => {
egui::Grid::new("comp-adv").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| {
ui.label(tr("Anchor:"));
egui::Grid::new("comp-anchor").spacing([2.0, 2.0]).show(ui, |ui| {
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 });
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 {
ui.end_row();
}
}
});
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue");
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested"));
auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested");
ui.end_row();
ui.label(egui::RichText::new(tr("Motion Blur")).strong());
ui.end_row();
ui.label(tr("Shutter Angle:"));
let r = ui.add(crate::widgets::drag_value(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(tr("Shutter Phase:"));
let r = ui.add(crate::widgets::drag_value(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(tr("Samples Per Frame:"));
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.samples).range(2..=64)));
auto.add("dialog.comp.samples", r.rect, "Samples Per Frame");
ui.end_row();
ui.label(tr("Adaptive Sample Limit:"));
let r = ui.add(crate::widgets::drag_value(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(tr("Renderer:"));
let r = egui::ComboBox::from_id_salt("comp-renderer").selected_text(tr(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" })).show_ui(
ui,
|ui| {
ui.selectable_value(&mut d.advanced_3d, false, tr("Classic 3D"));
ui.selectable_value(&mut d.advanced_3d, true, tr("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| {
let r = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.comp.ok", r.rect, "OK");
if r.clicked() {
out = Some(params(d));
}
let r = ui.button(tr("Cancel"));
auto.add("dialog.comp.cancel", r.rect, "Cancel");
if r.clicked() {
out = Some(serde_json::Value::Null);
}
});
});
out
}
/// `comp.new` / `comp.settings` parameters for the draft.
pub fn params(d: &CompDraft) -> serde_json::Value {
json!({
"name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps,
"duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit,
"preserveFrameRate": d.preserve_frame_rate, "preserveResolution": d.preserve_resolution,
"renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" },
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aspect_labels_match_ae() {
assert_eq!(aspect_label(1920.0, 1080.0), "16:9 (1.78)");
assert_eq!(aspect_label(1080.0, 1080.0), "1:1 (1.00)");
assert_eq!(aspect_label(1080.0, 1350.0), "4:5 (0.80)");
assert_eq!(aspect_label(720.0 * 0.9091, 480.0), "15:11 (1.36)");
}
#[test]
fn ae_timecode_drops_hour_padding() {
assert_eq!(ae_timecode("00;00;10;00"), "0;00;10;00");
assert_eq!(ae_timecode("12:00:00:00"), "12:00:00:00");
assert_eq!(ae_timecode("-00:00:01:00"), "-0:00:01:00");
}
#[test]
fn presets_resolve_by_value() {
let p = PRESETS.iter().find(|p| p.0 == "HDTV 1080 29.97").unwrap();
assert_eq!((p.1, p.2), (1920, 1080));
}
}