mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
M3.1: AE Composition Settings dialog (Basic/Advanced/3D Renderer, AE presets, pixel aspect, start timecode, resize anchor)
engine comp.settings/comp.new: anchor (shifts unparented layers and their position keys on resize), startTime/startTimecode, renderer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7f43d537ed
commit
9d70623624
@@ -13,12 +13,50 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
|
||||
}
|
||||
|
||||
fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let (ow, oh) = (c.width, c.height);
|
||||
if let Some(w) = p.get("width").and_then(Value::as_u64) {
|
||||
c.width = (w as u32).clamp(4, 30000);
|
||||
}
|
||||
if let Some(h) = p.get("height").and_then(Value::as_u64) {
|
||||
c.height = (h as u32).clamp(4, 30000);
|
||||
}
|
||||
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
|
||||
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
|
||||
if (c.width, c.height) != (ow, oh) {
|
||||
let a = p.get("anchor").and_then(Value::as_u64).unwrap_or(4).min(8);
|
||||
let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0;
|
||||
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
let Some(pr) = l.props.prop_mut("transform/position") else { continue };
|
||||
let shift = |v: &mut effectcraft_keyframe::Value| {
|
||||
if let effectcraft_keyframe::Value::Vec2(x) = v {
|
||||
x[0] += dx;
|
||||
x[1] += dy;
|
||||
} else if let effectcraft_keyframe::Value::Vec3(x) = v {
|
||||
x[0] += dx;
|
||||
x[1] += dy;
|
||||
}
|
||||
};
|
||||
shift(&mut pr.value);
|
||||
for k in &mut pr.keys {
|
||||
shift(&mut k.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = f_p(p, "startTime") {
|
||||
c.display_start = Tick::from_seconds_f64(t);
|
||||
} else if let Some(tc) = str_p(p, "startTimecode")
|
||||
&& let Ok(f) = effectcraft_time::parse_timecode(tc, c.frame_rate, c.frame_rate.supports_drop_frame(), 0)
|
||||
{
|
||||
c.display_start = c.frame_rate.tick_of(f);
|
||||
}
|
||||
match str_p(p, "renderer").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("classic3d" | "classic") => c.renderer = effectcraft_project::Renderer::Classic3D,
|
||||
Some("advanced3d" | "advanced") => c.renderer = effectcraft_project::Renderer::Advanced3D,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(r) = rate_p(p) {
|
||||
c.frame_rate = r;
|
||||
}
|
||||
@@ -205,7 +243,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Composition…",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, pixelAspect?, open?}",
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
@@ -214,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Composition Settings…",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?}",
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
||||
@@ -242,3 +242,27 @@ fn layer_tree_paths_resolve() {
|
||||
}
|
||||
assert!(checked > 20, "{checked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_settings_anchor_start_timecode_renderer() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "A", "width": 100, "height": 100, "duration": 2.0})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [10, 20]})).unwrap();
|
||||
let pos = |s: &Session| {
|
||||
let pr = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone();
|
||||
pr.keys[0].value.components()
|
||||
};
|
||||
// Center anchor: grow by 100x50 → shift by half.
|
||||
s.execute("comp.settings", json!({"width": 200, "height": 150})).unwrap();
|
||||
assert_eq!(pos(&s)[..2], [60.0, 45.0]);
|
||||
// Top-left anchor: no shift. Bottom-right: full shift.
|
||||
s.execute("comp.settings", json!({"width": 300, "anchor": 0})).unwrap();
|
||||
assert_eq!(pos(&s)[..2], [60.0, 45.0]);
|
||||
s.execute("comp.settings", json!({"width": 200, "height": 100, "anchor": 8})).unwrap();
|
||||
assert_eq!(pos(&s)[..2], [-40.0, -5.0]);
|
||||
s.execute("comp.settings", json!({"startTimecode": "0:00:01:00", "renderer": "advanced3D"})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.display_start, c.frame_rate.tick_of(30));
|
||||
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Composition Settings dialog (also New Composition), laid out like After Effects': name, then
|
||||
//! Basic / Advanced / 3D Renderer tabs, Preview checkbox, Cancel / OK.
|
||||
|
||||
use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode};
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
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 advanced_3d: bool,
|
||||
pub tab: Tab,
|
||||
/// Timecode text being edited (start, duration).
|
||||
pub start_tc: Option<String>,
|
||||
pub dur_tc: 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,
|
||||
advanced_3d: false,
|
||||
tab: Tab::Basic,
|
||||
start_tc: None,
|
||||
dur_tc: 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) {
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut out = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Composition Name:");
|
||||
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
|
||||
});
|
||||
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() {
|
||||
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("Preset:");
|
||||
let current = PRESETS
|
||||
.iter()
|
||||
.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| {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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 lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64));
|
||||
ui.checkbox(&mut d.lock_aspect, lbl);
|
||||
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("Height:");
|
||||
let (ow, oh) = (d.width, d.height);
|
||||
ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
|
||||
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("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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
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),
|
||||
);
|
||||
});
|
||||
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| {
|
||||
for f in FRAME_RATES {
|
||||
if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() {
|
||||
d.fps = *f;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label("frames per second");
|
||||
if rate.supports_drop_frame() {
|
||||
ui.label(egui::RichText::new("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(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));
|
||||
ui.end_row();
|
||||
ui.label("Duration:");
|
||||
ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1));
|
||||
ui.end_row();
|
||||
ui.label("Background Color:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.color_edit_button_rgb(&mut d.bg);
|
||||
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("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 });
|
||||
if ui.add(b).clicked() {
|
||||
d.anchor = i;
|
||||
}
|
||||
if i % 3 == 2 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
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("°"));
|
||||
ui.end_row();
|
||||
ui.label("Shutter Phase:");
|
||||
ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"));
|
||||
ui.end_row();
|
||||
ui.label("Samples Per Frame:");
|
||||
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
|
||||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
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() {
|
||||
out = Some(params(d));
|
||||
}
|
||||
if ui.button("Cancel").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,
|
||||
"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));
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,7 @@ use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompDraft {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: f64,
|
||||
pub duration: f64,
|
||||
pub bg: [f32; 3],
|
||||
pub lock_aspect: bool,
|
||||
}
|
||||
|
||||
impl Default for CompDraft {
|
||||
fn default() -> Self {
|
||||
CompDraft { name: "Comp 1".into(), width: 1920, height: 1080, fps: 29.97, duration: 10.0, bg: [0.0, 0.0, 0.0], lock_aspect: true }
|
||||
}
|
||||
}
|
||||
pub use super::comp_settings::CompDraft;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DialogState {
|
||||
@@ -50,10 +35,16 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
name: app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
pixel_aspect: c.pixel_aspect,
|
||||
fps: c.frame_rate.as_f64(),
|
||||
duration: c.duration.seconds(),
|
||||
start: c.display_start.seconds(),
|
||||
bg: c.background,
|
||||
lock_aspect: true,
|
||||
shutter_angle: c.shutter_angle,
|
||||
shutter_phase: c.shutter_phase,
|
||||
samples: c.motion_blur_samples,
|
||||
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
|
||||
..Default::default()
|
||||
};
|
||||
app.dialog_state.editing_existing = true;
|
||||
app.dialog = Some(Dialog::CompSettings);
|
||||
@@ -172,93 +163,25 @@ fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
|
||||
fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) {
|
||||
let mut close = false;
|
||||
let mut ok = false;
|
||||
let mut d = app.dialog_state.comp.clone();
|
||||
modal(ctx, if existing { "Composition Settings" } else { "Composition Settings — New" }, vec2(520.0, 420.0), t, |ui| {
|
||||
egui::Grid::new("comp-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
ui.label("Composition Name");
|
||||
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
|
||||
ui.end_row();
|
||||
ui.label("Preset");
|
||||
egui::ComboBox::from_id_salt("comp-preset").selected_text(format!("{}x{} {:.2}", d.width, d.height, d.fps)).show_ui(ui, |ui| {
|
||||
for (label, w, h, f) in [
|
||||
("HD · 1920x1080 · 29.97 fps", 1920, 1080, 29.97),
|
||||
("HD · 1920x1080 · 25 fps", 1920, 1080, 25.0),
|
||||
("HD · 1920x1080 · 23.976 fps", 1920, 1080, 23.976),
|
||||
("HD · 1280x720 · 29.97 fps", 1280, 720, 29.97),
|
||||
("UHD 4K · 3840x2160 · 29.97 fps", 3840, 2160, 29.97),
|
||||
("Social 9:16 · 1080x1920 · 30 fps", 1080, 1920, 30.0),
|
||||
("Social 1:1 · 1080x1080 · 30 fps", 1080, 1080, 30.0),
|
||||
("Social 4:5 · 1080x1350 · 30 fps", 1080, 1350, 30.0),
|
||||
("Cinema 4K · 4096x2160 · 24 fps", 4096, 2160, 24.0),
|
||||
] {
|
||||
if ui.selectable_label(false, label).clicked() {
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.fps = f;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Width / Height");
|
||||
ui.horizontal(|ui| {
|
||||
let ow = d.width;
|
||||
ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"));
|
||||
ui.label("×");
|
||||
let oh = d.height;
|
||||
ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"));
|
||||
ui.checkbox(&mut d.lock_aspect, "Lock aspect");
|
||||
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("Frame Rate");
|
||||
egui::ComboBox::from_id_salt("comp-fps").selected_text(format!("{:.3}", d.fps).trim_end_matches('0').trim_end_matches('.').to_string()).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for f in [23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 59.94, 60.0, 120.0] {
|
||||
if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() {
|
||||
d.fps = f;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Duration");
|
||||
ui.add(egui::DragValue::new(&mut d.duration).range(0.04..=86400.0).speed(0.1).suffix(" s"));
|
||||
ui.end_row();
|
||||
ui.label("Background Color");
|
||||
ui.color_edit_button_rgb(&mut d.bg);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(18.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() {
|
||||
ok = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let mut result = None;
|
||||
modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| {
|
||||
result = super::comp_settings::show(ui, &mut d, t);
|
||||
});
|
||||
if ui_enter(ctx) {
|
||||
ok = true;
|
||||
if result.is_none() && ui_enter(ctx) {
|
||||
result = Some(super::comp_settings::params(&d));
|
||||
}
|
||||
app.dialog_state.comp = d.clone();
|
||||
if ok {
|
||||
let params = json!({"name": d.name, "width": d.width, "height": d.height, "frameRate": d.fps, "duration": d.duration, "background": [d.bg[0], d.bg[1], d.bg[2]]});
|
||||
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
app.dialog_state.comp = d;
|
||||
match result {
|
||||
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 let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.dialog = None;
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Panel bodies.
|
||||
|
||||
pub mod comp_settings;
|
||||
pub mod dialogs;
|
||||
pub mod effect_controls;
|
||||
pub mod effects_presets;
|
||||
|
||||
Reference in New Issue
Block a user