//! 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 { 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; // Whole frames, at least one. c.duration = c.frame_rate.snap_nearest(Tick::from_seconds_f64(d)).max(c.frame_rate.frame_duration()); 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 { 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, rate.snap_nearest(Tick::from_seconds_f64(10.0))); // Settings ▸ 3D ▸ Default 3D Renderer. if s.prefs.three_d.default_renderer == "advanced" { c.renderer = effectcraft_project::Renderer::Advanced3D; } 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 { 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); super::model3d::sync_geometry_options(proj, cid); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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", [], None, "{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), ] }