Files
effectcraft/crates/engine/src/commands/comp.rs
T
Brandon ThomasandClaude Opus 5.5 9d70623624 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>
2026-10-01 21:57:08 -04:00

268 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p, time_p};
use crate::{EngineError, Result, Session, cmd};
pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
f_p(p, "frameRate").or(f_p(p, "fps")).map(FrameRate::from_f64)
}
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;
}
if let Some(d) = f_p(p, "duration") {
let old = c.duration;
c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds()));
if c.work_area.1 == old {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
c.background = [g(0), g(1), g(2)];
} else if let Some(hex) = str_p(p, "background").and_then(effectcraft_color::Rgba::from_hex) {
c.background = [hex.r, hex.g, hex.b];
}
if let Some(a) = f_p(p, "shutterAngle") {
c.shutter_angle = a.clamp(0.0, 720.0);
}
if let Some(a) = f_p(p, "shutterPhase") {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0));
apply_settings(&mut c, p);
let id = s.edit("New Composition", None, |proj, st| {
let mut name = name.clone();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{} {k}", name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end());
k += 1;
}
let id = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
st.project_selection = vec![id];
Ok(id)
})?;
if b_p(p, "open") != Some(false) {
s.open_comp(id);
}
Ok(json!({"comp": id.0}))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Composition Settings", None, |proj, _| {
if let Some(n) = str_p(p, "name")
&& let Some(it) = proj.item_mut(cid)
{
it.name = n.to_string();
}
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
apply_settings(c, p);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds()}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let id = match p.get("comp").or(p.get("item")) {
Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp)?,
_ => *s.state.project_selection.iter().find(|i| s.project.comp(**i).is_some()).ok_or_else(|| bad("comp.open", "missing `comp`"))?,
};
if s.project.comp(id).is_none() {
return Err(EngineError::NoComp);
}
s.open_comp(id);
Ok(json!({"comp": id.0}))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = comp_id(s, p)?;
s.state.open_comps.retain(|c| *c != id);
if s.state.active_comp == Some(id) {
s.state.active_comp = s.state.open_comps.last().copied();
s.state.selected_layers.clear();
s.state.selected_keys.clear();
s.state.selected_props.clear();
}
Ok(Value::Null)
}
fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
let begin = f_p(p, "start").map(Tick::from_seconds_f64);
let end = f_p(p, "end").map(Tick::from_seconds_f64);
let which = str_p(p, "set");
s.edit("Work Area", super::merge_p(p), |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = c.frame_duration();
match which {
Some("begin") => c.work_area.0 = t.min(c.work_area.1 - fd),
Some("end") => c.work_area.1 = (t + fd).max(c.work_area.0 + fd).min(c.duration),
_ => {}
}
if let Some(b) = begin {
c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd);
}
if let Some(e) = end {
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
}
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
l.out_point = (l.out_point - a).min(b - a);
}
c.duration = b - a;
c.work_area = (Tick::ZERO, b - a);
Ok(())
})?;
Ok(Value::Null)
}
fn comp_switch(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let name = str_p(p, "switch").ok_or_else(|| bad("comp.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let r = s.edit("Composition Switch", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let slot = match name.as_str() {
"hideShy" | "shy" => &mut c.hide_shy,
"motionBlur" => &mut c.enable_motion_blur,
"frameBlending" => &mut c.enable_frame_blending,
"draft3d" | "draft3D" => &mut c.draft_3d,
_ => return Err(bad("comp.setSwitch", "switch: hideShy|motionBlur|frameBlending|draft3d")),
};
*slot = v.unwrap_or(!*slot);
Ok(*slot)
})?;
Ok(json!(r))
}
fn add_marker(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = time_p(s, p, s.project.comp(cid));
let comment = str_p(p, "comment").unwrap_or("").to_string();
s.edit("Add Marker", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.markers.push(Marker { time: t, comment, ..Default::default() });
c.markers.sort_by_key(|m| m.time);
Ok(())
})?;
Ok(json!({"time": t.seconds()}))
}
fn poster(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let t = s.time();
s.edit("Set Poster Time", None, |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.poster_time = t;
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"comp.new",
"New Composition…",
["Composition"],
Some("Cmd+N"),
"{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
),
cmd!(
"comp.settings",
"Composition Settings…",
["Composition"],
Some("Cmd+K"),
"{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
),
cmd!("comp.setPosterTime", "Set Poster Time", ["Composition"], None, "{}", has_comp, poster),
cmd!("comp.trimToWorkArea", "Trim Comp to Work Area", ["Composition"], Some("Cmd+Shift+X"), "{comp?}", has_comp, trim_to_wa),
cmd!("comp.open", "Open Composition", [], None, "{comp: id|name}", always, open),
cmd!("comp.close", "Close Composition", [], Some("Cmd+W"), "{comp?}", has_comp, close),
cmd!("comp.workArea", "Set Work Area", [], None, "{start?, end?, set?: begin|end (at CTI)}", has_comp, work_area),
cmd!("comp.setSwitch", "Composition Switch", [], None, "{switch: hideShy|motionBlur|frameBlending|draft3d, value?}", has_comp, comp_switch),
cmd!("comp.addMarker", "Add Marker", [], Some("Num*"), "{time?, comment?}", has_comp, add_marker),
]
}