From 799b5b39ed7e127b260585b54bf6ec94a8d8ead9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:16:42 -0400 Subject: [PATCH] M3.8: stub burn-down Implement 20 formerly disabled menu stubs: Look at Selected/All Layers (2D viewer fit, 3D view cameras, active camera), Switch View Layout (1/2/4 views in the viewer) and Share View Options, Create Camera from 3D View, Add Output Module (every module encodes the item's frames), Pre-render (queue + Import & Replace Usage post-render action), Save Current Preview, RotoBezier / Convert To Bezier Path, Group / Ungroup Shapes, Set First Vertex, Free Transform Points, mask Motion Blur (sub-frame mask samples), Feather Falloff (smooth/linear), Hide Locked Masks, and workspace save / save as / edit. Tests incl. undo. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/frontend.rs | 3 + crates/engine/src/commands/layer_menu.rs | 2 +- crates/engine/src/commands/mask.rs | 11 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/paths.rs | 478 +++++++++++++++++++++ crates/engine/src/commands/render_queue.rs | 142 +++++- crates/engine/src/commands/stubs.rs | 20 - crates/engine/src/commands/three_d.rs | 121 ++++++ crates/engine/src/commands/view.rs | 16 + crates/engine/src/lib.rs | 19 +- crates/engine/src/menus.rs | 15 + crates/engine/src/render_queue.rs | 115 ++++- crates/engine/src/tests_menu_cmds.rs | 2 +- crates/engine/src/tests_stubs.rs | 336 +++++++++++++++ crates/project/src/build.rs | 10 +- crates/project/src/lib.rs | 2 +- crates/project/src/props.rs | 27 ++ crates/project/src/render_queue.rs | 37 ++ crates/render/src/cache.rs | 6 + crates/render/src/masks.rs | 53 ++- crates/ui-egui/src/header.rs | 6 +- crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/menus.rs | 82 +++- crates/ui-egui/src/panels/forms.rs | 10 + crates/ui-egui/src/panels/viewer.rs | 86 +++- crates/ui-egui/src/state.rs | 4 + 26 files changed, 1546 insertions(+), 68 deletions(-) create mode 100644 crates/engine/src/commands/paths.rs create mode 100644 crates/engine/src/tests_stubs.rs diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index b344c2a0..89464500 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -68,6 +68,9 @@ pub fn specs() -> Vec { "{name: Default|Standard|Small Screen|Animation|Effects|Motion Tracking|Paint|Text|Minimal|All Panels}", always ), + fe!("window.saveWorkspace", "Save Changes to this Workspace", ["Window", "Workspace"], None, "{}", always), + fe!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], None, "{name}", always), + fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always), fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always), fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always), fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], None, "{}", has_comp), diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index f183e7a0..b441c366 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -256,7 +256,7 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result { /// Masks the command applies to: `mask` (index / uid / name) on `layer`, else selected masks, /// else every mask of the selected layers. -fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { +pub(crate) fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { let (cid, ids) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let mut out = vec![]; diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 617346c1..8c671ecd 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -44,9 +44,13 @@ fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?; let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?; let uid = g.uid; + let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; let r = f(&mut sp)?; + if roto { + super::paths::roto_smooth(&mut sp); + } pr.set_value_at(lt, KV::Path(sp)); Ok((uid, r)) }) @@ -182,9 +186,14 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP for ((l, m), idx) in &groups { let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue }; let lt = layer.layer_time(t); - let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue }; + let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue }; + let roto = super::paths::is_roto(g); + let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; f(&mut sp, idx); + if roto { + super::paths::roto_smooth(&mut sp); + } pr.set_value_at(lt, KV::Path(sp)); } Ok(()) diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 52bec4e2..d175a81b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -17,6 +17,7 @@ mod layer_time; mod link; mod lottie; mod mask; +mod paths; mod prop; mod query; mod render_queue; @@ -92,6 +93,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(anim::specs()); v.extend(link::specs()); v.extend(mask::specs()); + v.extend(paths::specs()); v.extend(effect::specs()); v.extend(time::specs()); v.extend(render_queue::specs()); diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs new file mode 100644 index 00000000..0c0436ab --- /dev/null +++ b/crates/engine/src/commands/paths.rs @@ -0,0 +1,478 @@ +//! Layer ▸ Mask and Shape Path (RotoBezier, Convert To Bezier Path, Group / Ungroup Shapes, Set +//! First Vertex, Free Transform Points) and Layer ▸ Mask ▸ Motion Blur, Feather Falloff and Hide +//! Locked Masks. + +use effectcraft_keyframe::{ShapePath, Value as KV}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::layer_menu::target_masks; +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p}; +use crate::{EngineError, Result, Session, VertexRef, cmd}; + +// ---------------------------------------------------------------- RotoBezier + +/// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its +/// neighbours, a third of the way to them (Catmull-Rom); open ends get none. +pub(crate) fn roto_smooth(sp: &mut ShapePath) { + let n = sp.vertices.len(); + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + for i in 0..n { + let (prev, next) = if sp.closed { + ((i + n - 1) % n, (i + 1) % n) + } else if i == 0 || i + 1 == n { + sp.in_tangents[i] = [0.0; 2]; + sp.out_tangents[i] = [0.0; 2]; + continue; + } else { + (i - 1, i + 1) + }; + let (a, b) = (sp.vertices[prev], sp.vertices[next]); + let d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0]; + sp.out_tangents[i] = d; + sp.in_tangents[i] = [-d[0], -d[1]]; + } +} + +/// Whether a mask uses RotoBezier tangents. +pub(crate) fn is_roto(g: &PropGroup) -> bool { + matches!(g.kind, GroupKind::Mask { roto_bezier: true, .. }) +} + +/// Apply `f` to a mask path's static value and every key. +fn map_path(g: &mut PropGroup, f: &dyn Fn(&mut ShapePath)) { + let Some(pr) = g.get_mut("path") else { return }; + if let KV::Path(sp) = &mut pr.value { + f(sp); + } + for k in &mut pr.keys { + if let KV::Path(sp) = &mut k.value { + f(sp); + } + } +} + +/// Mask targets: masks of the selected vertices, else [`target_masks`]. +fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { + if p.get("mask").is_none() && p.get("layer").is_none() && !s.state.selected_vertices.is_empty() { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let mut v: Vec<(LayerId, Uid)> = s.state.selected_vertices.iter().map(|v| (v.layer, v.mask)).collect(); + v.sort(); + v.dedup(); + return Ok((cid, v)); + } + target_masks(s, p) +} + +fn edit_mask_groups(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut PropGroup) -> Result<()>) -> Result { + let (cid, targets) = masks_for(s, p)?; + if targets.is_empty() { + return Err(bad(label, "no masks: select a layer with masks (or mask vertices)")); + } + let n = targets.len(); + s.edit(label, None, |proj, _| { + for (lid, uid) in &targets { + let l = layer_mut(proj, cid, *lid)?; + if let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) { + f(g)?; + } + } + Ok(()) + })?; + Ok(json!({"masks": n})) +} + +fn roto_bezier(s: &mut Session, p: &Value) -> Result { + let (cid, targets) = masks_for(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let all_on = targets.iter().all(|(l, u)| comp.layer(*l).and_then(|l| l.masks()).and_then(|m| m.find_group(*u)).is_some_and(is_roto)); + let on = b_p(p, "value").unwrap_or(!all_on); + edit_mask_groups(s, p, "RotoBezier", |g| { + if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind { + *roto_bezier = on; + } + if on { + map_path(g, &roto_smooth); + } + Ok(()) + })?; + Ok(json!({"rotoBezier": on})) +} + +fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { + edit_mask_groups(s, p, "Convert To Bezier Path", |g| { + if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind { + *roto_bezier = false; + } + Ok(()) + }) +} + +// ---------------------------------------------------------------- mask options + +fn mask_motion_blur(s: &mut Session, p: &Value) -> Result { + let mode = match str_p(p, "mode").unwrap_or("") { + "sameAsLayer" | "same" => MaskMotionBlur::SameAsLayer, + "on" => MaskMotionBlur::On, + "off" => MaskMotionBlur::Off, + _ => return Err(bad("layer.mask.motionBlur", "mode: sameAsLayer|on|off")), + }; + edit_mask_groups(s, p, "Mask Motion Blur", |g| { + if let GroupKind::Mask { motion_blur, .. } = &mut g.kind { + *motion_blur = mode; + } + Ok(()) + }) +} + +fn feather_falloff(s: &mut Session, p: &Value) -> Result { + let mode = match str_p(p, "mode").unwrap_or("") { + "smooth" => FeatherFalloff::Smooth, + "linear" => FeatherFalloff::Linear, + _ => return Err(bad("layer.mask.featherFalloff", "mode: smooth|linear")), + }; + edit_mask_groups(s, p, "Mask Feather Falloff", |g| { + if let GroupKind::Mask { feather_falloff, .. } = &mut g.kind { + *feather_falloff = mode; + } + Ok(()) + }) +} + +fn hide_locked(s: &mut Session, p: &Value) -> Result { + s.state.hide_locked_masks = b_p(p, "value").unwrap_or(!s.state.hide_locked_masks); + Ok(json!(s.state.hide_locked_masks)) +} + +// ---------------------------------------------------------------- vertices + +fn has_one_vertex(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_vertices.len() == 1 { Ok(()) } else { Err("select one mask vertex".into()) } +} + +fn set_first_vertex(s: &mut Session, p: &Value) -> Result { + let v = match (p.get("layer"), p.get("mask"), p.get("index").and_then(Value::as_u64)) { + (Some(_), Some(m), Some(i)) => { + let (_, lid) = layer_p(s, p, "path.setFirstVertex")?; + let mask = m.as_u64().ok_or_else(|| bad("path.setFirstVertex", "`mask` is the mask uid"))?; + VertexRef { layer: lid, mask, index: i as usize } + } + _ => *s + .state + .selected_vertices + .first() + .filter(|_| s.state.selected_vertices.len() == 1) + .ok_or_else(|| bad("path.setFirstVertex", "select one mask vertex"))?, + }; + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + s.edit("Set First Vertex", None, |proj, st| { + let l = layer_mut(proj, cid, v.layer)?; + let g = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(v.mask)).ok_or_else(|| bad("path.setFirstVertex", "no such mask"))?; + let k = v.index; + let mut err = None; + map_path(g, &|sp| { + if k < sp.vertices.len() && sp.closed { + sp.vertices.rotate_left(k); + let n = sp.in_tangents.len(); + sp.in_tangents.rotate_left(k.min(n)); + let n = sp.out_tangents.len(); + sp.out_tangents.rotate_left(k.min(n)); + } + }); + if let Some(KV::Path(sp)) = g.get("path").map(|p| &p.value) + && !sp.closed + { + err = Some(bad("path.setFirstVertex", "open paths always start at their first vertex")); + } + if let Some(e) = err { + return Err(e); + } + st.selected_vertices = vec![VertexRef { index: 0, ..v }]; + Ok(()) + })?; + Ok(json!({"first": v.index})) +} + +/// Free Transform Points: scale / rotate / move the selected vertices (or whole masks) about an +/// anchor (default: the centre of their bounding box), at the current time. +fn free_transform(s: &mut Session, p: &Value) -> Result { + let scale = match p.get("scale") { + Some(Value::Number(n)) => { + let k = n.as_f64().unwrap_or(100.0) / 100.0; + [k, k] + } + Some(Value::Array(a)) if a.len() >= 2 => [a[0].as_f64().unwrap_or(100.0) / 100.0, a[1].as_f64().unwrap_or(100.0) / 100.0], + _ => [1.0, 1.0], + }; + let rot = f_p(p, "rotation").unwrap_or(0.0).to_radians(); + let off = + p.get("offset").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); + let off = off.unwrap_or([0.0; 2]); + let anchor = + p.get("anchor").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); + if scale == [1.0, 1.0] && rot == 0.0 && off == [0.0, 0.0] { + return Err(bad("path.freeTransform", "pass scale (% or [x%, y%]), rotation (deg) and/or offset [dx, dy]; anchor? [x, y]")); + } + let (cid, targets) = masks_for(s, p)?; + let selected: Vec = s.state.selected_vertices.clone(); + let t = s.time(); + let (c, sn) = (rot.cos(), rot.sin()); + let lin = |v: [f64; 2]| { + let x = v[0] * scale[0]; + let y = v[1] * scale[1]; + [x * c - y * sn, x * sn + y * c] + }; + let n = s.edit("Free Transform Points", None, |proj, _| { + let mut n = 0; + for (lid, uid) in &targets { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue }; + let roto = is_roto(g); + let Some(pr) = g.get_mut("path") else { continue }; + let KV::Path(mut sp) = pr.value_at(lt) else { continue }; + let idx: Vec = { + let sel: Vec = + selected.iter().filter(|v| v.layer == *lid && v.mask == *uid).map(|v| v.index).filter(|i| *i < sp.vertices.len()).collect(); + if sel.is_empty() { (0..sp.vertices.len()).collect() } else { sel } + }; + if idx.is_empty() { + continue; + } + let a = anchor.unwrap_or_else(|| { + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for &i in &idx { + let v = sp.vertices[i]; + lo = [lo[0].min(v[0]), lo[1].min(v[1])]; + hi = [hi[0].max(v[0]), hi[1].max(v[1])]; + } + [(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0] + }); + for &i in &idx { + let v = sp.vertices[i]; + let q = lin([v[0] - a[0], v[1] - a[1]]); + sp.vertices[i] = [q[0] + a[0] + off[0], q[1] + a[1] + off[1]]; + if let Some(tn) = sp.in_tangents.get_mut(i) { + *tn = lin(*tn); + } + if let Some(tn) = sp.out_tangents.get_mut(i) { + *tn = lin(*tn); + } + n += 1; + } + if roto { + roto_smooth(&mut sp); + } + pr.set_value_at(lt, KV::Path(sp)); + } + Ok(n) + })?; + Ok(json!({"vertices": n})) +} + +// ---------------------------------------------------------------- shape groups + +const SHAPE_ITEMS: &[&str] = &[ + "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", + "wiggle", "merge", +]; + +/// Selected shape items (contents entries) of shape layers: (layer, uid). +fn selected_shape_items(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let mut out = vec![]; + if let Some(Value::Array(a)) = p.get("items") { + let (_, lid) = layer_p(s, p, "path.groupShapes")?; + out.extend(a.iter().filter_map(Value::as_u64).map(|u| (lid, u))); + } else { + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid).filter(|l| matches!(l.source, LayerSource::Shape)) else { continue }; + if l.props + .sub("contents") + .and_then(|c| c.find_group(*uid)) + .is_some_and(|g| g.uid != l.props.sub("contents").map(|c| c.uid).unwrap_or(0) && SHAPE_ITEMS.contains(&g.match_id.as_str())) + { + out.push((*lid, *uid)); + } + } + } + Ok((cid, out)) +} + +fn has_shape_items(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let comp = s.active_comp().ok_or("no composition")?; + let ok = s.state.selected_props.iter().any(|(l, u)| { + comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| SHAPE_ITEMS.contains(&g.match_id.as_str())) + }); + if ok { Ok(()) } else { Err("select shape items (Contents ▸ …) in the timeline".into()) } +} + +fn group_shapes(s: &mut Session, p: &Value) -> Result { + let (cid, items) = selected_shape_items(s, p)?; + let Some(lid) = items.first().map(|x| x.0) else { return Err(bad("path.groupShapes", "select shape items to group")) }; + let uids: Vec = items.iter().filter(|(l, _)| *l == lid).map(|(_, u)| *u).collect(); + let g = s.edit("Group Shapes", None, |proj, st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.groupShapes", "not a shape layer"))?; + let parent = contents.parent_of_mut(uids[0]).ok_or_else(|| bad("path.groupShapes", "no such shape item"))?; + if !uids.iter().all(|u| parent.children.iter().any(|c| c.uid() == *u)) { + return Err(bad("path.groupShapes", "the shape items must be in the same group")); + } + let at = parent.children.iter().position(|c| uids.contains(&c.uid())).unwrap_or(0); + let mut moved = vec![]; + parent.children.retain(|c| { + if uids.contains(&c.uid()) { + moved.push(c.clone()); + false + } else { + true + } + }); + let n = parent.children.iter().filter(|c| c.match_id() == "group").count() + 1; + let groups: Vec = moved.into_iter().filter_map(|c| if let Node::Group(g) = c { Some(g) } else { None }).collect(); + let g = build::shape_group(&mut Ids(&mut next), &format!("Group {n}"), groups); + let uid = g.uid; + parent.children.insert(at.min(parent.children.len()), Node::Group(g)); + proj.next_id = next; + st.selected_props = vec![(lid, uid)]; + Ok(uid) + })?; + Ok(json!({"group": g})) +} + +/// Add `d` to every positional value in a shape item subtree (shape positions, path vertices, +/// group positions, gradient points, twist centres) — static values and keys. +fn offset_items(g: &mut PropGroup, d: [f64; 2]) { + let shift = |v: &mut KV| match v { + KV::Vec2(a) => { + a[0] += d[0]; + a[1] += d[1]; + } + KV::Path(sp) => { + for q in &mut sp.vertices { + q[0] += d[0]; + q[1] += d[1]; + } + } + _ => {} + }; + let props: &[&str] = match g.match_id.as_str() { + "rect" | "ellipse" | "star" => &["position"], + "path" => &["path"], + "gfill" | "gstroke" => &["start", "end"], + "twist" => &["center"], + _ => &[], + }; + for m in props { + if let Some(pr) = g.get_mut(m) { + shift(&mut pr.value); + for k in &mut pr.keys { + shift(&mut k.value); + } + } + } + if g.match_id == "group" + && let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position")) + { + shift(&mut pr.value); + for k in &mut pr.keys { + shift(&mut k.value); + } + } +} + +fn ungroup_shapes(s: &mut Session, p: &Value) -> Result { + let (cid, items) = selected_shape_items(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let groups: Vec<(LayerId, Uid)> = items + .into_iter() + .filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| g.match_id == "group")) + .collect(); + if groups.is_empty() { + return Err(bad("path.ungroupShapes", "select a shape group to ungroup")); + } + let n = groups.len(); + s.edit("Ungroup Shapes", None, |proj, st| { + let mut sel = vec![]; + for (lid, uid) in &groups { + let l = layer_mut(proj, cid, *lid)?; + let Some(contents) = l.props.sub_mut("contents") else { continue }; + let Some(parent) = contents.parent_of_mut(*uid) else { continue }; + let Some(i) = parent.children.iter().position(|c| c.uid() == *uid) else { continue }; + let Node::Group(g) = parent.children.remove(i) else { continue }; + // The group's transform must be a plain (static) move: it is baked into the items. + let tr = g.sub("transform"); + let get = |m: &str| tr.and_then(|t| t.get(m)); + let animated = tr.is_some_and(|t| t.any_animated()); + let v2 = |m: &str, d: [f64; 2]| get(m).map(|p| p.value.as_vec2()).unwrap_or(d); + let f = |m: &str, d: f64| get(m).map(|p| p.value.as_f64()).unwrap_or(d); + let (a, pos, sc) = (v2("anchor", [0.0; 2]), v2("position", [0.0; 2]), v2("scale", [100.0; 2])); + if animated || sc != [100.0, 100.0] || f("rotation", 0.0) != 0.0 || f("skew", 0.0) != 0.0 || f("opacity", 100.0) != 100.0 { + return Err(bad( + "path.ungroupShapes", + format!("`{}` has a scale, rotation, skew, opacity or animated transform: reset it before ungrouping", g.name), + )); + } + let d = [pos[0] - a[0], pos[1] - a[1]]; + let mut inner: Vec = g.sub("contents").map(|c| c.children.clone()).unwrap_or_default(); + for c in &mut inner { + if let Node::Group(ig) = c { + if d != [0.0, 0.0] { + offset_items(ig, d); + } + sel.push((*lid, ig.uid)); + } + } + for (k, c) in inner.into_iter().enumerate() { + parent.children.insert(i + k, c); + } + } + st.selected_props = sel; + Ok(()) + })?; + Ok(json!({"ungrouped": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier), + cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier), + cmd!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes), + cmd!( + "path.ungroupShapes", + "Ungroup Shapes", + ["Layer", "Mask and Shape Path"], + Some("Cmd+Shift+G"), + "{layer?, items?: [group uid]}", + has_shape_items, + ungroup_shapes + ), + cmd!( + "path.setFirstVertex", + "Set First Vertex", + ["Layer", "Mask and Shape Path"], + None, + "{layer?, mask?: uid, index?}", + has_one_vertex, + set_first_vertex + ), + cmd!( + "path.freeTransform", + "Free Transform Points", + ["Layer", "Mask and Shape Path"], + None, + "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}", + has_layers, + free_transform + ), + cmd!("layer.mask.motionBlur", "Motion Blur", [], None, "{layer?, mask?, mode: sameAsLayer|on|off}", has_layers, mask_motion_blur), + cmd!("layer.mask.featherFalloff", "Feather Falloff", [], None, "{layer?, mask?, mode: smooth|linear}", has_layers, feather_falloff), + cmd!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], None, "{value?}", has_comp, hide_locked), + ] +} diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d07b03a7..1b8c3c9b 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -5,12 +5,12 @@ //! `index` (1-based, the # column). use effectcraft_project::render_queue::{ - AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan, + AudioOutput, Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan, }; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, f_p, str_p}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p}; use crate::{EngineError, Result, Session, cmd, query}; fn can_add(s: &Session) -> std::result::Result<(), String> { @@ -195,6 +195,24 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { Ok(any) } +/// `module` (1-based; 1 = the first output module). +fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result { + let n = p.get("module").and_then(Value::as_u64).unwrap_or(1) as usize; + let have = s.project.render_queue[i].extra_outputs.len() + 1; + if n == 0 || n > have { + return Err(bad(cmd, format!("module must be 1..={have}"))); + } + Ok(n - 1) +} + +fn module_ref(it: &RenderQueueItem, m: usize) -> &OutputModule { + if m == 0 { &it.output } else { &it.extra_outputs[m - 1] } +} + +fn module_mut(it: &mut RenderQueueItem, m: usize) -> &mut OutputModule { + if m == 0 { &mut it.output } else { &mut it.extra_outputs[m - 1] } +} + /// A finished item that is edited goes back to Queued (AE re-queues on change). fn requeue(it: &mut RenderQueueItem) { if it.render && !matches!(it.status, RenderStatus::Rendering) { @@ -215,6 +233,18 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value { o.insert("outputPath".into(), json!(s.resolve_output(it))); o.insert("renderSettingsSummary".into(), json!(it.settings.summary())); o.insert("outputModuleSummary".into(), json!(it.output.summary())); + let extra: Vec = it + .extra_outputs + .iter() + .map(|om| { + let mut c = it.clone(); + c.output = om.clone(); + json!({"summary": om.summary(), "outputPath": s.resolve_output(&c)}) + }) + .collect(); + o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1)); + o.insert("extraOutputs".into(), json!(extra)); + o.insert("postRenderAction".into(), json!(it.post_render.label())); o.insert("width".into(), json!(w)); o.insert("height".into(), json!(h)); o.insert("frames".into(), json!(frames)); @@ -285,13 +315,14 @@ fn set_render_settings(s: &mut Session, p: &Value) -> Result { fn set_output_module(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setOutputModule")?; - let mut om = s.project.render_queue[i].output.clone(); + let m = module_p(s, i, p, "renderQueue.setOutputModule")?; + let mut om = module_ref(&s.project.render_queue[i], m).clone(); if !apply_output(&mut om, p, "renderQueue.setOutputModule")? { return Err(bad("renderQueue.setOutputModule", "nothing to change")); } s.edit("Output Module Settings", None, |proj, _| { let it = &mut proj.render_queue[i]; - it.output = om; + *module_mut(it, m) = om; requeue(it); Ok(()) })?; @@ -301,11 +332,12 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { fn set_output(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setOutput")?; let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?; - let mut om = s.project.render_queue[i].output.clone(); + let m = module_p(s, i, p, "renderQueue.setOutput")?; + let mut om = module_ref(&s.project.render_queue[i], m).clone(); apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?; s.edit("Output To", None, |proj, _| { let it = &mut proj.render_queue[i]; - it.output = om; + *module_mut(it, m) = om; requeue(it); Ok(()) })?; @@ -378,6 +410,89 @@ fn formats(s: &mut Session, _: &Value) -> Result { Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::>()})) } +/// `item?|index?`, else the last item (the queue's newest entry). +fn item_or_last(s: &Session, p: &Value, cmd: &str) -> Result { + if p.get("item").is_some() || p.get("index").is_some() { + return item_index(s, p, cmd); + } + s.project.render_queue.len().checked_sub(1).ok_or_else(|| bad(cmd, "the render queue is empty")) +} + +/// Composition ▸ Add Output Module: another output module on a render item (the same frames are +/// encoded once more). +fn add_output_module(s: &mut Session, p: &Value) -> Result { + let i = item_or_last(s, p, "render.addOutputModule")?; + let it = &s.project.render_queue[i]; + let n = it.extra_outputs.len() + 2; + let mut om = it.output.clone(); + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?; + om = OutputModule::for_format(f); + } + if str_p(p, "output").is_none() && str_p(p, "path").is_none() { + // Same name with a module suffix so the files don't collide. + om.output = match om.output.rfind('.') { + Some(dot) => format!("{}_{n}{}", &om.output[..dot], &om.output[dot..]), + None => format!("{}_{n}", om.output), + }; + } + let mut op = p.clone(); + if let Some(o) = op.as_object_mut() { + o.remove("format"); + } + apply_output(&mut om, &op, "render.addOutputModule")?; + s.edit("Add Output Module", None, |proj, _| { + let it = &mut proj.render_queue[i]; + it.extra_outputs.push(om); + requeue(it); + Ok(()) + })?; + let mut v = item_json(s, &s.project.render_queue[i], i); + v["module"] = json!(n); + Ok(v) +} + +/// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render +/// action imports the result and replaces the comp's uses. +fn pre_render(s: &mut Session, p: &Value) -> Result { + let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?; + let mut it = RenderQueueItem::new(0, cid); + it.output = OutputModule::for_format(OutputFormat::ProRes); + it.output.prores_profile = ProResProfile::P4444; + it.output.channels = Channels::Rgba; + it.output.output = "[compName]_prerender.[fileExtension]".into(); + apply_output(&mut it.output, p, "render.preRender")?; + it.post_render = PostRenderAction::ImportAndReplace; + let id = s.edit("Pre-render", None, |proj, _| { + it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; + let id = it.id; + proj.render_queue.push(it); + Ok(id) + })?; + let idx = s.project.render_queue.len() - 1; + let mut v = item_json(s, &s.project.render_queue[idx], idx); + v["item"] = json!(id); + Ok(v) +} + +/// Composition ▸ Save Current Preview…: render the work area of the comp straight to a movie file. +fn save_current_preview(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("render.saveCurrentPreview", "missing `path`"))?.to_string(); + let cid = comp_id(s, p)?; + let format = OutputFormat::from_path(&path).unwrap_or(OutputFormat::H264); + let exporter = s.exporter.clone().ok_or_else(|| EngineError::Other("export is not available in this build".into()))?; + if !exporter.formats().contains(&format) { + return Err(EngineError::Other(format!("{} export is not available", format.label()))); + } + let mut item = RenderQueueItem::new(0, cid); + item.output = OutputModule::for_format(format); + item.output.output = path.clone(); + let job = crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), item: &item, path: &path }; + let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; + s.toast(format!("Saved preview to {}", r.path)); + Ok(serde_json::to_value(&r).unwrap_or_default()) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -405,7 +520,7 @@ pub fn specs() -> Vec { "Output Module Settings...", [], None, - "{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}", + "{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}", has_items, set_output_module ), @@ -414,10 +529,21 @@ pub fn specs() -> Vec { "Output To...", [], None, - "{item?|index?, path: file path or template like [compName].[fileExtension]}", + "{item?|index?, module?: n, path: file path or template like [compName].[fileExtension]}", has_items, set_output ), + cmd!( + "render.addOutputModule", + "Add Output Module", + ["Composition"], + None, + "{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, audio?}", + has_items, + add_output_module + ), + cmd!("render.preRender", "Pre-render...", ["Composition"], None, "{comp?, output?: path|template, format?}", can_add, pre_render), + cmd!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], None, "{comp?, path}", has_comp, save_current_preview), cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item), cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate), cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render), diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index b8b97575..d8342497 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -21,9 +21,6 @@ pub fn specs() -> Vec { stub!("app.hideOthers", "Hide Others", [], "{}"), stub!("app.showAll", "Show All", [], "{}"), // Render queue / export (export milestone). - stub!("render.addOutputModule", "Add Output Module", ["Composition"], "{}"), - stub!("render.preRender", "Pre-render...", ["Composition"], "{}"), - stub!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], "{path}"), stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"), // Proxies (no proxy model yet). stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"), @@ -37,15 +34,6 @@ pub fn specs() -> Vec { stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"), // Mask options not in the mask model yet; pen / vertex editing (timeline milestone). - stub!("layer.mask.motionBlur", "Motion Blur", [], "{mode: sameAsLayer|on|off}"), - stub!("layer.mask.featherFalloff", "Feather Falloff", [], "{mode: smooth|linear}"), - stub!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], "{}"), - stub!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], "{}"), - stub!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], "{}"), - stub!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], "Cmd+G", "{}"), - stub!("path.ungroupShapes", "Ungroup Shapes", ["Layer", "Mask and Shape Path"], "Cmd+Shift+G", "{}"), - stub!("path.setFirstVertex", "Set First Vertex", ["Layer", "Mask and Shape Path"], "{}"), - stub!("path.freeTransform", "Free Transform Points", ["Layer", "Mask and Shape Path"], "{}"), // Time (timeline milestone). stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"), stub!("layer.environment", "Environment Layer", ["Layer"], "{}"), @@ -54,7 +42,6 @@ pub fn specs() -> Vec { stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), // Cameras / lights / materials / 3D views (3D milestone). - stub!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], "{}"), stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"), stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"), stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"), @@ -67,11 +54,7 @@ pub fn specs() -> Vec { stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"), stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"), stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"), - stub!("view.layout", "Switch View Layout", [], "{views: 1|2|4}"), - stub!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], "{}"), stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"), - stub!("view.lookAtSelected", "Look at Selected Layers", ["View"], "Cmd+Alt+Shift+\\", "{}"), - stub!("view.lookAtAll", "Look at All Layers", ["View"], "{}"), stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"), stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"), // Color management. @@ -87,9 +70,6 @@ pub fn specs() -> Vec { stub!("track.mask", "Track Mask", ["Animation"], "{}"), stub!("track.property", "Track this Property", ["Animation"], "{}"), // Workspaces and panels that don't exist yet. - stub!("window.saveWorkspace", "Save Changes to this Workspace", ["Window", "Workspace"], "{}"), - stub!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], "{name}"), - stub!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], "{}"), stub!("window.unavailablePanel", "Panel", [], "{panel}"), ] } diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index de733128..aea32105 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -529,6 +529,122 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +// ---------------------------------------------------------------- look at / camera from view + +/// World-space points spanning the layers' content at comp time `t` (content-box corners +/// through each layer's world matrix; cameras and lights contribute their position). +fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { + let Some(comp) = s.project.comp(cid) else { return vec![] }; + let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() }; + let mut out = vec![]; + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + let m = ctx.world_matrix(l); + match effectcraft_render::content_bounds(&ctx, l) { + Some([x0, y0, x1, y1]) => { + for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] { + out.push(m.apply(vec3(x, y, 0.0))); + } + } + None => out.push(m.apply(Vec3::ZERO)), + } + } + out +} + +fn look_at(s: &mut Session, p: &Value, all: bool) -> Result { + let cmd = if all { "view.lookAtAll" } else { "view.lookAtSelected" }; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); + let ids: Vec = if all { + comp.layers.iter().filter(|l| l.is_active_at(t) && !l.is_camera() && !l.is_light()).map(|l| l.id).collect() + } else { + let (_, sel) = super::layers_p(s, p)?; + sel + }; + if ids.is_empty() { + return Err(bad(cmd, if all { "the composition has no visible layers" } else { "select layers first" })); + } + let pts = layer_points(s, cid, &ids, t); + let (mut lo, mut hi) = (vec3(f64::MAX, f64::MAX, f64::MAX), vec3(f64::MIN, f64::MIN, f64::MIN)); + for q in &pts { + lo = vec3(lo.x.min(q.x), lo.y.min(q.y), lo.z.min(q.z)); + hi = vec3(hi.x.max(q.x), hi.y.max(q.y), hi.z.max(q.z)); + } + let c = (lo + hi) * 0.5; + let r = ((hi - lo).length() * 0.5).max(1.0); + let (w, h) = (comp.width as f64, comp.height as f64); + let fit = w.min(h); + let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + if !comp.has_3d() { + // 2D: frame the layers in the viewer (zoom and pan to their comp-space bounds). + let rect = [lo.x, lo.y, hi.x, hi.y]; + super::frontend(s, "view.lookAt", &json!({"rect": rect}))?; + return Ok(json!({"rect": rect})); + } + if cur != View3D::ActiveCamera { + let vs = views(s, cid); + let mut vc = vs.cam(cur, w, h); + let fwd = (Vec3::from(vc.poi) - Vec3::from(vc.eye)).normalize(); + if vc.ortho { + vc.zoom = fit / (2.2 * r); + vc.eye = camera::arr(c - fwd * 10_000.0); + } else { + vc.eye = camera::arr(c - fwd * (vc.zoom * r * 2.2 / fit)); + } + vc.poi = camera::arr(c); + vs.cams.insert(cur, vc); + return Ok(json!({"view": cur.id(), "eye": vc.eye, "poi": vc.poi, "zoom": vc.zoom})); + } + let cam = comp.active_camera(t).map(|l| l.id).ok_or_else(|| bad(cmd, "the comp has no camera layer: switch to a 3D view (View ▸ Switch 3D View)"))?; + s.edit(if all { "Look at All Layers" } else { "Look at Selected Layers" }, None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let lt = l.layer_time(t); + let pos = Vec3::from(get_at(l, "transform/position", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, -1000.0])); + let zoom = get_at(l, "cameraOptions/zoom", lt).map(|v| v.as_f64()).unwrap_or(1000.0); + let two = camera::is_two_node(l); + let fwd = if two { + let poi = Vec3::from(get_at(l, "transform/poi", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])); + (poi - pos).normalize() + } else { + let o = get_at(l, "transform/orientation", lt).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); + Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)).normalize() + }; + let eye = c - fwd * (zoom * r * 2.2 / fit); + set_at(l, "transform/position", lt, KV::Vec3(camera::arr(eye))); + if two { + set_at(l, "transform/poi", lt, KV::Vec3(camera::arr(c))); + } + Ok(json!({"layer": cam.0, "position": camera::arr(eye), "poi": camera::arr(c)})) + }) +} + +/// Layer ▸ Camera ▸ Create Camera from 3D View: a two-node camera matching the current 3D view. +fn camera_from_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let (w, h) = (comp.width as f64, comp.height as f64); + let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + if cur == View3D::ActiveCamera { + return Err(bad("camera.fromView", "switch to a 3D view first (View ▸ Switch 3D View)")); + } + let vc = s.state.views3d.get(&cid).cloned().unwrap_or_default().cam(cur, w, h); + let poi = Vec3::from(vc.poi); + let fwd = (poi - Vec3::from(vc.eye)).normalize(); + let zoom = if vc.ortho { effectcraft_geom::default_camera_zoom(w) } else { vc.zoom }; + let eye = if vc.ortho { poi - fwd * zoom } else { Vec3::from(vc.eye) }; + let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); + let name = comp.unique_layer_name("Camera 1"); + let params = json!({"position": camera::arr(eye), "poi": camera::arr(poi), "zoom": zoom}); + let id = s.edit("Create Camera from 3D View", None, |proj, st| { + let mut l = build::layer(proj, &comp, &name, LayerSource::Camera, (comp.width, comp.height), None); + apply_camera(&mut l, &comp, ¶ms, t, "camera.fromView")?; + insert_layer(proj, st, cid, l) + })?; + switch_view(s, cid, View3D::ActiveCamera); + Ok(json!({"layer": id.0, "from": cur.id()})) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -596,6 +712,11 @@ pub fn specs() -> Vec { cmd!("view.3d.custom2", "Custom View 2", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom2), cmd!("view.3d.custom3", "Custom View 3", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom3), cmd!("view.3d.last", "Switch to Last 3D View", ["View"], None, "{comp?}", has_comp, last_view), + cmd!("view.lookAtSelected", "Look at Selected Layers", ["View"], Some("Cmd+Alt+Shift+\\"), "{comp?, layers?}", super::has_layers, |s, p| look_at( + s, p, false + )), + cmd!("view.lookAtAll", "Look at All Layers", ["View"], None, "{comp?}", has_comp, |s, p| look_at(s, p, true)), + cmd!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], None, "{comp?}", has_comp, camera_from_view), cmd!("view.reset3DView", "Reset 3D View", ["View"], None, "{comp?}", has_comp, reset_view), cmd!("view.set3DViewCamera", "Set 3D View Camera", [], None, "{view, eye? [x,y,z], poi? [x,y,z], zoom?}", has_comp, set_view_camera), cmd!( diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index 83b23cd3..b3d86565 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -75,12 +75,28 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.region_of_interest)) } +fn view_layout(s: &mut Session, p: &Value) -> Result { + let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?; + if !matches!(n, 1 | 2 | 4) { + return Err(bad("view.layout", "views: 1|2|4")); + } + s.state.view_layout = n as u8; + Ok(json!({"views": n})) +} + +fn share_view_options(s: &mut Session, p: &Value) -> Result { + s.state.share_view_options = super::b_p(p, "value").unwrap_or(!s.state.share_view_options); + Ok(json!(s.state.share_view_options)) +} + pub fn specs() -> Vec { vec![ cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide), cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides), cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides), cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides), + cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), + cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options), cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03fc2f6b..2295c4a5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -153,6 +153,19 @@ pub struct EditorState { /// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras). #[serde(default)] pub views3d: std::collections::BTreeMap, + /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. + #[serde(default = "one_view")] + pub view_layout: u8, + /// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view). + #[serde(default)] + pub share_view_options: bool, + /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). + #[serde(default)] + pub hide_locked_masks: bool, +} + +fn one_view() -> u8 { + 1 } /// What Copy with Property Links / Copy Expression Only put on the clipboard. @@ -218,7 +231,7 @@ impl Default for Session { revision: 0, saved_revision: 0, path: None, - state: EditorState { snapping: true, ..Default::default() }, + state: EditorState { snapping: true, view_layout: 1, ..Default::default() }, services: Arc::new(FsServices), footage: Arc::new(NoFootage), expr: None, @@ -436,7 +449,7 @@ impl Session { p.fix_next_id(); self.project = Arc::new(p); self.history = History::default(); - self.state = EditorState { snapping: true, ..Default::default() }; + self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; self.path = path; self.bump(); self.saved_revision = self.revision; @@ -458,6 +471,8 @@ mod tests_lottie; #[cfg(test)] mod tests_menu_cmds; #[cfg(test)] +mod tests_stubs; +#[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_text; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 783896ef..2d7a8816 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -151,6 +151,21 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { }) } "layer.markersLock" => Some(layer?.markers_locked), + "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), + "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), + "view.shareViewOptions" => Some(s.state.share_view_options), + "layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => { + use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur}; + let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None }; + Some(match command { + "path.rotoBezier" => roto_bezier, + "layer.mask.motionBlur" => matches!( + (pstr("mode")?, motion_blur), + ("sameAsLayer", MaskMotionBlur::SameAsLayer) | ("on", MaskMotionBlur::On) | ("off", MaskMotionBlur::Off) + ), + _ => matches!((pstr("mode")?, feather_falloff), ("smooth", FeatherFalloff::Smooth) | ("linear", FeatherFalloff::Linear)), + }) + } _ => None, } } diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 61bc0fab..95c90824 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -10,8 +10,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; -use effectcraft_project::{ItemId, Project}; +use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; +use effectcraft_project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_render::{ExprHost, FootageSource}; use serde::Serialize; @@ -121,7 +121,8 @@ struct Work { footage: Arc, expr: Option>, exporter: Arc, - items: Vec<(RenderQueueItem, String)>, + /// Per queue item: one (item with that output module, resolved path) per output module. + items: Vec>, } fn unix_now() -> u64 { @@ -134,7 +135,8 @@ fn run_work(w: Work, shared: &JobShared) { let mut s = lock(&shared.state); s.items_total = w.items.len(); } - for (k, (item, path)) in w.items.iter().enumerate() { + for (k, modules) in w.items.iter().enumerate() { + let Some((item, _)) = modules.first() else { continue }; if shared.cancel.load(Ordering::Relaxed) { lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None }); continue; @@ -149,16 +151,29 @@ fn run_work(w: Work, shared: &JobShared) { s.items_done = k; s.remaining = None; } - let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item, path }; - let r = w.exporter.export(&job, &mut |done, total| { - let mut s = lock(&shared.state); - s.done = done; - s.total = total; - s.elapsed = t0.elapsed().as_secs_f64(); - s.item_elapsed = t1.elapsed().as_secs_f64(); - s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64); - !shared.cancel.load(Ordering::Relaxed) - }); + // Every output module encodes the same frames (Composition ▸ Add Output Module); the + // first module's file is the item's output. + let mut r: Result = Err("no output module".into()); + for (mi, (mitem, path)) in modules.iter().enumerate() { + let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item: mitem, path }; + let rr = w.exporter.export(&job, &mut |done, total| { + let mut s = lock(&shared.state); + s.done = done; + s.total = total; + s.elapsed = t0.elapsed().as_secs_f64(); + s.item_elapsed = t1.elapsed().as_secs_f64(); + s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64); + !shared.cancel.load(Ordering::Relaxed) + }); + match rr { + Ok(res) if mi == 0 => r = Ok(res), + Ok(_) => {} + Err(e) => { + r = Err(e); + break; + } + } + } let seconds = t1.elapsed().as_secs_f64(); let up = match r { Ok(res) => ItemUpdate::Finished { id: item.id, status: RenderStatus::Done, seconds, output: Some(res.path) }, @@ -240,11 +255,24 @@ impl Session { let mut items = Vec::new(); let mut needs_output = Vec::new(); for it in self.project.render_queue.iter().filter(|i| i.is_queued()) { - match self.resolve_output(it) { - Some(p) if formats.contains(&it.output.format) => items.push((it.clone(), p)), - Some(_) => needs_output.push((it.id, RenderStatus::Failed(format!("{} export is not available", it.output.format.label())))), - None if self.project.comp(it.comp).is_none() => needs_output.push((it.id, RenderStatus::Failed("composition missing".into()))), - None => needs_output.push((it.id, RenderStatus::NeedsOutput)), + let mut modules = vec![]; + let mut problem = None; + for om in it.output_modules() { + let mut m = it.clone(); + m.output = om.clone(); + match self.resolve_output(&m) { + Some(p) if formats.contains(&om.format) => modules.push((m, p)), + Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))), + None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())), + None => problem = Some(RenderStatus::NeedsOutput), + } + if problem.is_some() { + break; + } + } + match problem { + Some(st) => needs_output.push((it.id, st)), + None => items.push(modules), } } if !needs_output.is_empty() { @@ -259,7 +287,7 @@ impl Session { if items.is_empty() { return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into()); } - let ids: Vec = items.iter().map(|(i, _)| i.id).collect(); + let ids: Vec = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect(); let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items }; let shared = Arc::new(JobShared::default()); if wait { @@ -292,6 +320,7 @@ impl Session { let updates = std::mem::take(&mut *lock(&job.shared.updates)); let finished = job.is_finished(); let changed = !updates.is_empty(); + let mut post: Vec<(u64, String)> = vec![]; if changed { let p = Arc::make_mut(&mut self.project); for u in &updates { @@ -310,6 +339,11 @@ impl Session { if output.is_some() { i.last_output = output.clone(); } + if let (RenderStatus::Done, Some(o)) = (status, output) + && !i.post_render.is_none() + { + post.push((*id, o.clone())); + } } } } @@ -333,9 +367,50 @@ impl Session { }; self.events.push(Event::Toast { message: msg, error: failed > 0 }); } + for (id, path) in post { + if let Err(e) = self.post_render_action(id, &path) { + self.events.push(Event::Toast { message: format!("Post-render action: {e}"), error: true }); + } + } changed || finished } + /// Run an item's Post-Render Action: import the rendered file, and for Import & Replace + /// Usage swap every layer that used the rendered composition to the new footage (one undo + /// step). + pub fn post_render_action(&mut self, id: u64, path: &str) -> Result, String> { + let Some(item) = self.project.render_queue.iter().find(|i| i.id == id).cloned() else { return Ok(None) }; + if item.post_render.is_none() { + return Ok(None); + } + let importer = self.importer.clone().ok_or("media import is not available in this build")?; + let footage = importer.probe(path)?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let replace = item.post_render == PostRenderAction::ImportAndReplace; + let comp = item.comp; + let label = if replace { "Pre-render: Import & Replace Usage" } else { "Post-Render: Import" }; + self.edit(label, None, |proj, st| { + let fid = proj.add_item(&name, effectcraft_color::Label::Aqua, None, ItemKind::Footage(footage)); + if replace { + let ids: Vec = proj.comps().map(|(i, _)| *i).filter(|i| *i != comp).collect(); + for cid in ids { + let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if item == comp))); + if !uses { + continue; + } + if let Some(c) = proj.comp_mut(cid) { + for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { + l.source = LayerSource::Footage { item: fid }; + } + } + } + } + st.project_selection = vec![fid]; + Ok(Some(fid)) + }) + .map_err(|e| e.to_string()) + } + /// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel. pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option { if p.get("comp").is_some() { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 71007654..040c494e 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.create", "track.motion"] { + for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs new file mode 100644 index 00000000..c1356ff2 --- /dev/null +++ b/crates/engine/src/tests_stubs.rs @@ -0,0 +1,336 @@ +//! Menu entries that used to be disabled stubs (M3.8 burn-down): mask and shape path commands, +//! mask options, Look at Layers, view layouts, Create Camera from 3D View, output modules, +//! Pre-render and Save Current Preview. + +use std::sync::{Arc, Mutex}; + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::render_queue::{OutputFormat, PostRenderAction}; +use effectcraft_project::{FeatherFalloff, Footage, FootageKind, GroupKind, LayerId, LayerSource, MaskMotionBlur}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::{Event, ExportJob, ExportResult, Exporter, Importer, Session}; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +/// A solid with one closed square mask; returns (layer id, mask uid). +fn masked(s: &mut Session) -> (u64, u64) { + let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap(); + s.execute("mask.new", json!({"layer": l, "vertices": [[100, 100], [300, 100], [300, 200], [100, 200]], "closed": true})).unwrap(); + let m = layer(s, l).masks().unwrap().groups().next().unwrap().uid; + (l, m) +} + +fn mask_path(s: &Session, l: u64, m: u64) -> effectcraft_keyframe::ShapePath { + let ly = layer(s, l); + let g = ly.masks().unwrap().find_group(m).unwrap(); + g.get("path").unwrap().value.as_path().unwrap().clone() +} + +fn mask_kind(s: &Session, l: u64) -> GroupKind { + layer(s, l).masks().unwrap().groups().next().unwrap().kind.clone() +} + +#[test] +fn roto_bezier_and_convert_to_bezier() { + let mut s = comp(); + let (l, m) = masked(&mut s); + assert!(mask_path(&s, l, m).out_tangents.iter().all(|t| *t == [0.0, 0.0])); + s.execute("path.rotoBezier", json!({"layer": l})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. })); + let p = mask_path(&s, l, m); + // Vertex 0 at (100,100): neighbours (100,200) and (300,100) → tangent (200,-100)/6. + assert!((p.out_tangents[0][0] - 200.0 / 6.0).abs() < 1e-9 && (p.out_tangents[0][1] + 100.0 / 6.0).abs() < 1e-9, "{:?}", p.out_tangents); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + assert_eq!(crate::menus::checked(&s, "path.rotoBezier", &json!({})), Some(true)); + // Moving a vertex keeps the tangents automatic. + s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap(); + s.execute("mask.moveVertices", json!({"delta": [50, 50]})).unwrap(); + let q = mask_path(&s, l, m); + assert_ne!(q.out_tangents[1], p.out_tangents[1]); + s.execute("path.convertToBezier", json!({"layer": l})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: false, .. })); + assert_eq!(mask_path(&s, l, m), q, "convert keeps the tangents"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. })); +} + +#[test] +fn set_first_vertex_and_free_transform() { + let mut s = comp(); + let (l, m) = masked(&mut s); + s.state.selected_vertices.clear(); + assert!(!s.is_enabled("path.setFirstVertex")); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap(); + assert!(s.is_enabled("path.setFirstVertex")); + s.execute("path.setFirstVertex", json!({})).unwrap(); + let p = mask_path(&s, l, m); + assert_eq!(p.vertices[0], [300.0, 200.0]); + assert_eq!(p.vertices[3], [300.0, 100.0]); + assert_eq!(s.state.selected_vertices[0].index, 0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m).vertices[0], [100.0, 100.0]); + // Free Transform the whole mask: scale 50 % about its centre, then move. + s.state.selected_vertices.clear(); + s.execute("path.freeTransform", json!({"layer": l, "scale": 50, "offset": [10, 0]})).unwrap(); + let p = mask_path(&s, l, m); + assert_eq!(p.vertices[0], [160.0, 125.0]); + assert_eq!(p.vertices[2], [260.0, 175.0]); + // Rotation of selected vertices only. + s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap(); + s.execute("path.freeTransform", json!({"rotation": 180})).unwrap(); + let q = mask_path(&s, l, m); + assert!((q.vertices[0][0] - 260.0).abs() < 1e-9 && (q.vertices[1][0] - 160.0).abs() < 1e-9); + assert_eq!(q.vertices[2], p.vertices[2]); + assert!(s.execute("path.freeTransform", json!({})).is_err()); +} + +#[test] +fn group_and_ungroup_shapes() { + let mut s = comp(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let contents = layer(&s, l).props.sub("contents").unwrap().clone(); + let outer = contents.groups().next().unwrap().clone(); + let inner: Vec = outer.sub("contents").unwrap().groups().map(|g| g.uid).collect(); + assert!(inner.len() >= 2, "{outer:?}"); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.state.selected_props = inner.iter().map(|u| (LayerId(l), *u)).collect(); + assert!(s.is_enabled("path.groupShapes")); + let g = s.execute("path.groupShapes", json!({})).unwrap()["group"].as_u64().unwrap(); + let ly = layer(&s, l); + let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap(); + let kids: Vec<&str> = o.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); + assert_eq!(kids, ["group"]); + assert_eq!(o.find_group(g).unwrap().sub("contents").unwrap().groups().count(), inner.len()); + let before = s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap(); + // Move the new group, then ungroup: the offset is baked into its items. + let gp = ly.props.sub("contents").unwrap().find_group(g).unwrap().sub("transform").unwrap().get("position").unwrap().uid; + s.edit("t", None, |proj, _| { + let cid = proj.comps().next().map(|(i, _)| *i).unwrap(); + let l = proj.comp_mut(cid).unwrap().layer_mut(LayerId(l)).unwrap(); + l.props.find_mut(gp).unwrap().value = KV::Vec2([0.0, 0.0]); + Ok(()) + }) + .unwrap(); + s.state.selected_props = vec![(LayerId(l), g)]; + s.execute("path.ungroupShapes", json!({})).unwrap(); + let ly = layer(&s, l); + let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap(); + assert_eq!(o.sub("contents").unwrap().groups().count(), inner.len()); + assert_eq!(s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap(), before); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, l).props.find_group(g).is_some()); +} + +#[test] +fn mask_motion_blur_feather_falloff_and_hide_locked() { + let mut s = comp(); + let (l, _) = masked(&mut s); + s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 40})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let smooth = s.render_rgba8(cid, Tick::ZERO, 0).unwrap(); + s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. })); + let linear = s.render_rgba8(cid, Tick::ZERO, 0).unwrap(); + assert_ne!(smooth, linear); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + assert_eq!(crate::menus::checked(&s, "layer.mask.featherFalloff", &json!({"mode": "linear"})), Some(true)); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. })); + assert!(s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "sometimes"})).is_err()); + // Mask motion blur blurs an animated mask path. + s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 0})).unwrap(); + let still = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.selectVertices", json!({"vertices": []})).unwrap(); + s.execute("path.freeTransform", json!({"layer": l, "offset": [80, 0]})).unwrap(); + let blurred = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); + let soft = |img: &(u32, u32, Vec)| img.2.chunks(4).filter(|p| p[0] > 10 && p[0] < 245).count(); + assert!(soft(&blurred) > soft(&still) + 100, "{} vs {}", soft(&blurred), soft(&still)); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap(); + let sharp = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap(); + assert!(soft(&sharp) < soft(&blurred)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. })); + // Hide Locked Masks is a viewer toggle. + assert!(s.execute("layer.mask.hideLocked", json!({})).unwrap().as_bool().unwrap()); + assert_eq!(crate::menus::checked(&s, "layer.mask.hideLocked", &json!({})), Some(true)); +} + +#[test] +fn look_at_layers_2d_and_3d() { + let mut s = comp(); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [50, 60]})).unwrap(); + s.drain_events(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let r = s.execute("view.lookAtSelected", json!({})).unwrap(); + assert_eq!(r["rect"], json!([30.0, 50.0, 70.0, 70.0])); + assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "view.lookAt"))); + // 3D: a custom view frames the layers; the active camera view moves the camera (undoable). + s.execute("layer.setSwitch", json!({"layer": a, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.newCamera", json!({})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("view.3d.custom1", json!({})).unwrap(); + let r = s.execute("view.lookAtAll", json!({})).unwrap(); + let poi = r["poi"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!((poi[0] - 50.0).abs() < 1e-6 && (poi[1] - 60.0).abs() < 1e-6, "{r}"); + s.execute("view.3d.activeCamera", json!({})).unwrap(); + let cam = s.active_comp().unwrap().layers.iter().find(|l| l.is_camera()).unwrap().id; + let before = s.active_comp().unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value.clone(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("view.lookAtSelected", json!({})).unwrap(); + let c = s.project.comp(cid).unwrap().layer(cam).unwrap(); + assert_eq!(c.props.prop("transform/poi").unwrap().value.as_vec3()[..2], [50.0, 60.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.comp(cid).unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value, before); +} + +#[test] +fn camera_from_view_and_view_layout() { + let mut s = comp(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("camera.fromView", json!({})).is_err(), "needs a 3D view"); + s.execute("view.3d.custom2", json!({})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let vc = s.state.views3d[&cid].cam(effectcraft_render::three_d::View3D::Custom2, 400.0, 300.0); + let r = s.execute("camera.fromView", json!({})).unwrap(); + let cam = layer(&s, r["layer"].as_u64().unwrap()); + assert!(cam.is_camera()); + let pos = cam.props.prop("transform/position").unwrap().value.as_vec3(); + assert!((0..3).all(|i| (pos[i] - vc.eye[i]).abs() < 1e-6), "{pos:?} {:?}", vc.eye); + assert_eq!(s.state.views3d[&cid].current, effectcraft_render::three_d::View3D::ActiveCamera); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.active_comp().unwrap().layers.iter().any(|l| l.is_camera())); + // Switch View Layout. + assert_eq!(s.state.view_layout, 1); + s.execute("view.layout", json!({"views": 2})).unwrap(); + assert_eq!(s.state.view_layout, 2); + assert_eq!(crate::menus::checked(&s, "view.layout", &json!({"views": 2})), Some(true)); + assert!(s.execute("view.layout", json!({"views": 3})).is_err()); + assert!(s.execute("view.shareViewOptions", json!({})).unwrap().as_bool().unwrap()); +} + +struct MockExporter { + log: Mutex>, +} + +impl Exporter for MockExporter { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + self.log.lock().unwrap().push(format!("{:?}:{}", job.item.output.format, job.path)); + Ok(ExportResult { path: job.path.to_string(), frames: 1, ..Default::default() }) + } +} + +struct MockImporter; + +impl Importer for MockImporter { + fn probe(&self, path: &str) -> Result { + Ok(Footage { + path: path.into(), + kind: FootageKind::Video, + width: 400, + height: 300, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + native_rate: None, + duration: Tick::from_seconds_f64(4.0), + has_video: true, + has_audio: false, + alpha: Default::default(), + premul_color: [0.0; 3], + loop_count: 1, + codec: "mock".into(), + missing: false, + sequence: vec![], + }) + } +} + +fn rq() -> (Session, Arc) { + let mut s = comp(); + let ex = Arc::new(MockExporter { log: Default::default() }); + s.exporter = Some(ex.clone()); + s.importer = Some(Arc::new(MockImporter)); + (s, ex) +} + +#[test] +fn add_output_module_encodes_twice() { + let (mut s, ex) = rq(); + assert!(!s.is_enabled("render.addOutputModule")); + s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap(); + let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap(); + assert_eq!(r["module"], 2); + assert_eq!(r["outputModules"], 2); + let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string(); + assert!(extra.ends_with(".png"), "{extra}"); + // Module 2 is editable on its own. + s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap(); + assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err()); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(*ex.log.lock().unwrap(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]); + assert_eq!(s.project.render_queue[0].status.label(), "Done"); + assert_eq!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/om/main.mp4")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit"); +} + +#[test] +fn pre_render_imports_and_replaces() { + let (mut s, ex) = rq(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.open_comp(inner); + let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap(); + assert_eq!(r["postRenderAction"], "Import & Replace Usage"); + assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(ex.log.lock().unwrap().len(), 1); + let l = &s.project.comp(outer).unwrap().layers[0]; + let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) }; + assert!(matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/pre/inner.mov")); + s.execute("edit.undo", json!({})).unwrap(); + assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. })); +} + +#[test] +fn save_current_preview_writes_a_movie() { + let (mut s, ex) = rq(); + let r = s.execute("render.saveCurrentPreview", json!({"path": "/tmp/prev/clip.mov"})).unwrap(); + assert_eq!(r["path"], "/tmp/prev/clip.mov"); + assert_eq!(*ex.log.lock().unwrap(), vec!["ProRes:/tmp/prev/clip.mov".to_string()]); + assert!(s.project.render_queue.is_empty(), "not queued"); +} + +#[test] +fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { + let mut s = comp(); + for id in ["view.layout", "view.lookAtAll", "camera.fromView", "render.saveCurrentPreview", "render.preRender", "layer.mask.hideLocked"] { + assert!(s.is_enabled(id), "{id}"); + } + for id in ["window.saveWorkspace", "window.saveWorkspaceAs", "window.editWorkspaces"] { + s.execute(id, json!({"name": "Mine"})).unwrap(); + assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); + } + // What remains a stub is still disabled. + for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] { + assert!(!s.is_enabled(id), "{id}"); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index d491efac..12e384d6 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -71,7 +71,15 @@ pub const MASK_COLORS: [[u8; 3]; 8] = [ pub fn mask(ids: &mut Ids, name: &str, path: ShapePath, mode: MaskMode, color: [u8; 3]) -> PropGroup { let mut g = ids.group("mask", name); - g.kind = GroupKind::Mask { mode, inverted: false, color, locked: false }; + g.kind = GroupKind::Mask { + mode, + inverted: false, + color, + locked: false, + motion_blur: Default::default(), + feather_falloff: Default::default(), + roto_bezier: false, + }; g.with(ids.prop("path", "Mask Path", Value::Path(path)).with_ui(ParamUi::Path)) .with(ids.prop("feather", "Mask Feather", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Pixels)) .with(ids.prop("opacity", "Mask Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0))) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 9e8cf949..7fb40bae 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, Label}; use effectcraft_time::{FrameRate, Tick}; -pub use props::{Expression, GroupKind, MaskMode, Node, ParamUi, PropGroup, Property, Uid, parse_path}; +pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path}; use serde::{Deserialize, Serialize}; pub use effectcraft_keyframe as keyframe; diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 8b6e28ec..58f66433 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -162,6 +162,15 @@ pub enum GroupKind { inverted: bool, color: [u8; 3], locked: bool, + /// Layer ▸ Mask ▸ Motion Blur. + #[serde(default)] + motion_blur: MaskMotionBlur, + /// Layer ▸ Mask ▸ Feather Falloff. + #[serde(default)] + feather_falloff: FeatherFalloff, + /// Layer ▸ Mask and Shape Path ▸ RotoBezier: tangents follow the vertices automatically. + #[serde(default)] + roto_bezier: bool, }, Effect { /// Effect spec id, e.g. `ec.blur.gaussian`. @@ -169,6 +178,24 @@ pub enum GroupKind { }, } +/// Mask motion blur (sub-frame samples of an animated mask path). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MaskMotionBlur { + /// Blur when the layer's Motion Blur switch is on. + #[default] + SameAsLayer, + On, + Off, +} + +/// How a mask's feather ramps: Smooth (Gaussian) or Linear. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FeatherFalloff { + #[default] + Smooth, + Linear, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MaskMode { None, diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 1eac5be1..c41462b5 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -401,6 +401,37 @@ pub struct RenderQueueItem { /// Resolved path of the last render (first file for sequences). #[serde(default)] pub last_output: Option, + /// Further Output Modules (Composition ▸ Add Output Module): the same frames are encoded + /// once per module. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_outputs: Vec, + /// Post-Render Action of the first output module. + #[serde(default, skip_serializing_if = "PostRenderAction::is_none")] + pub post_render: PostRenderAction, +} + +/// What happens after an item renders (Output Module ▸ Post-Render Action). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PostRenderAction { + #[default] + None, + /// Import the rendered file into the project. + Import, + /// Import it and replace every use of the rendered composition (Composition ▸ Pre-render). + ImportAndReplace, +} + +impl PostRenderAction { + pub fn is_none(&self) -> bool { + *self == PostRenderAction::None + } + pub fn label(self) -> &'static str { + match self { + PostRenderAction::None => "None", + PostRenderAction::Import => "Import", + PostRenderAction::ImportAndReplace => "Import & Replace Usage", + } + } } impl RenderQueueItem { @@ -415,8 +446,14 @@ impl RenderQueueItem { started: None, render_time: None, last_output: None, + extra_outputs: vec![], + post_render: PostRenderAction::None, } } + /// Every output module, the first one first. + pub fn output_modules(&self) -> impl Iterator { + std::iter::once(&self.output).chain(self.extra_outputs.iter()) + } /// Will be rendered by the next Render. pub fn is_queued(&self) -> bool { self.render && matches!(self.status, RenderStatus::Queued) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index fa721f06..14ada1ec 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -272,6 +272,12 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool layer.id.hash(&mut h); hash_debug(&mut h, &layer.source); hash_debug(&mut h, &layer.switches); + // Mask motion blur samples the comp's shutter. + if layer.masks().is_some_and(|m| !m.children.is_empty()) { + ctx.comp.enable_motion_blur.hash(&mut h); + ctx.comp.shutter_angle.to_bits().hash(&mut h); + ctx.comp.shutter_phase.to_bits().hash(&mut h); + } for c in &layer.props.children { // Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]). if let Node::Group(g) = c diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index f2805065..b18a069f 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -4,14 +4,52 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; use effectcraft_path::{FillRule, StrokeStyle}; -use effectcraft_project::{GroupKind, Layer, MaskMode}; -use effectcraft_raster::{Mask, gaussian_blur}; +use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; +use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; use crate::eval::EvalCtx; -/// Coverage of one mask in buffer pixels (before mode/opacity/invert). +/// Sub-frame samples for mask motion blur (animated mask paths only). +const MASK_BLUR_SAMPLES: usize = 8; + +/// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur +/// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated +/// path is averaged over the comp's shutter. fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { + let mb = match g.kind { + GroupKind::Mask { motion_blur, .. } => match motion_blur { + MaskMotionBlur::On => true, + MaskMotionBlur::Off => false, + MaskMotionBlur::SameAsLayer => layer.switches.motion_blur, + }, + _ => false, + }; + let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression()); + if mb && animated && ctx.comp.enable_motion_blur { + let fd = ctx.comp.frame_duration().seconds(); + let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); + let mut acc: Option = None; + let k = 1.0 / MASK_BLUR_SAMPLES as f32; + for i in 0..MASK_BLUR_SAMPLES { + let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64; + let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd)); + let Some(c) = coverage_at(&sub, layer, g, buf) else { continue }; + match &mut acc { + None => { + let mut c = c; + c.data.iter_mut().for_each(|v| *v *= k); + acc = Some(c); + } + Some(a) => a.data.iter_mut().zip(&c.data).for_each(|(a, c)| *a += c * k), + } + } + return acc; + } + coverage_at(ctx, layer, g, buf) +} + +fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; let path = effectcraft_path::to_kurbo(&sp); let (w, h) = (buf.img.width, buf.img.height); @@ -33,7 +71,14 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); - let b = gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false); + let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + let b = if linear { + // One box pass: a straight ramp `feather` pixels wide across the edge. + let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); + box_blur(&img, r(feather[0]), r(feather[1]), 1, false) + } else { + gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false) + }; cov = Mask::from_alpha(&b); } Some(cov) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 6ed55cb6..d26c0bd2 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -193,9 +193,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { - for w in crate::dock::WORKSPACES { - if ui.selectable_label(app.ui.workspace == w, w).clicked() { - app.set_workspace(w); + for w in app.workspace_names() { + if ui.selectable_label(app.ui.workspace == w, &w).clicked() { + app.set_workspace(&w); ui.close(); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01a2d36d..04878963 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -174,7 +174,14 @@ impl EffectcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - self.ui.dock = dock::workspace(name); + self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); + } + + /// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace). + pub fn workspace_names(&self) -> Vec { + let mut v: Vec = dock::WORKSPACES.iter().map(|s| s.to_string()).collect(); + v.extend(self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned()); + v } pub fn show_panel(&mut self, p: PanelKind) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4ea3b5b5..6cc01006 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -430,11 +430,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "window.workspace" => { let want = p.get("name").and_then(Value::as_str).unwrap_or("Default").to_ascii_lowercase().replace(' ', ""); - let name = crate::dock::WORKSPACES - .iter() + let name = app + .workspace_names() + .into_iter() .find(|w| w.to_ascii_lowercase().replace(' ', "") == want) .ok_or_else(|| format!("unknown workspace `{want}`"))?; - app.set_workspace(name); + app.set_workspace(&name); json!({"workspace": name}) } "window.resetWorkspace" => { @@ -442,6 +443,80 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.set_workspace(&name); Value::Null } + "window.saveWorkspace" => { + let name = app.ui.workspace.clone(); + app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); + json!({"workspace": name}) + } + "window.saveWorkspaceAs" => { + let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) else { + crate::panels::dialogs::form( + app, + "New Workspace", + "window.saveWorkspaceAs", + json!({}), + vec![crate::panels::dialogs::Field::text("name", "Name", "Untitled Workspace")], + ); + return Ok(Value::Null); + }; + app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); + app.ui.workspace = name.clone(); + json!({"workspace": name}) + } + "window.editWorkspaces" => { + let Some(name) = p.get("name").and_then(Value::as_str).map(str::to_string) else { + let cur = app.ui.workspace.clone(); + crate::panels::dialogs::form( + app, + "Edit Workspaces", + "window.editWorkspaces", + json!({}), + vec![ + crate::panels::dialogs::Field::text("name", "Workspace", &cur), + crate::panels::dialogs::Field::text("rename", "Rename to", &cur), + crate::panels::dialogs::Field::bool("delete", "Delete", false), + ], + ); + return Ok(Value::Null); + }; + let builtin = crate::dock::WORKSPACES.contains(&name.as_str()); + if p.get("delete").and_then(Value::as_bool) == Some(true) { + if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { + return Err(format!("no workspace `{name}`")); + } + if app.ui.workspace == name { + app.set_workspace("Default"); + } + return Ok(json!({"deleted": name})); + } + if let Some(new) = p.get("rename").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty() && *n != name) { + if builtin { + return Err(format!("`{name}` is a built-in workspace: save it as a new workspace instead")); + } + let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?; + app.ui.saved_workspaces.insert(new.to_string(), dock); + if app.ui.workspace == name { + app.ui.workspace = new.to_string(); + } + return Ok(json!({"renamed": new})); + } + json!({"workspaces": app.workspace_names()}) + } + "view.lookAt" => { + let r: Vec = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + if r.len() == 4 { + let map: Option = ctx.data(|d| d.get_temp(egui::Id::new("viewer-map"))); + if let Some(m) = map { + let (rw, rh) = (((r[2] - r[0]) as f32).max(1.0), ((r[3] - r[1]) as f32).max(1.0)); + let zoom = ((m.area.width() - 40.0) / rw).min((m.area.height() - 40.0) / rh).clamp(0.015, 32.0); + let rc = [(r[0] + r[2]) as f32 / 2.0, (r[1] + r[3]) as f32 / 2.0]; + let v = &mut app.ui.viewer; + v.zoom = Some(zoom); + v.pan = [m.comp[0] * zoom / 2.0 - rc[0] * zoom, m.comp[1] * zoom / 2.0 - rc[1] * zoom]; + } + } + Value::Null + } "comp.flowchart" | "comp.miniFlowchart" => { app.show_panel(PanelKind::Flowchart); Value::Null @@ -486,6 +561,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json"])), "file.exportLottie" => ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), + "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")), "file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])), "file.replaceFootage" => ("path", Ask::Import), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 8965076e..5183b8b4 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -137,6 +137,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { }; (title.into(), fields) } + "path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => ( + "Free Transform Points".into(), + vec![ + Field::num("scale[0]", "Scale X (%)", 100.0), + Field::num("scale[1]", "Scale Y (%)", 100.0), + Field::num("rotation", "Rotation (degrees)", 0.0), + Field::num("offset[0]", "Move X", 0.0), + Field::num("offset[1]", "Move Y", 0.0), + ], + ), "layer.mask.set" if !has(p, &["value"]) => { let field = p.get("field").and_then(Value::as_str).unwrap_or("feather"); let first = layer.and_then(|l| l.masks()).and_then(|m| m.groups().next()).and_then(|g| g.get(field)).map(|pr| pr.value_at(lt).as_f64()); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index caf0f9a0..477441e6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -201,6 +201,82 @@ fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 { } } +/// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle. +fn aux_views( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + comp: &effectcraft_engine::project::Comp, + cid: effectcraft_engine::project::ItemId, + full: Rect, + bg: Color32, +) -> Rect { + let n = app.session.state.view_layout; + if n < 2 { + return full; + } + let gap = 2.0; + let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { + let mid = full.center().x; + (Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), View3D::Top)]) + } else { + let c = full.center(); + let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + ( + q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), + vec![ + (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), View3D::Top), + (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front), + (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right), + ], + ) + }; + let ctx = ui.ctx().clone(); + let p = ui.painter().clone(); + p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); + let t = app.session.time(); + let (cw, ch) = (comp.width as f32, comp.height as f32); + let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); + let share = app.session.state.share_view_options; + for (i, (r, v)) in aux.into_iter().enumerate() { + p.rect_filled(r, 0.0, bg); + let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); + let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); + let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; + let cam = views.cam(v, comp.width as f64, comp.height as f64).state(); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h); + h.finish() + }; + let id = egui::Id::new(("viewer-aux", i)); + let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); + let tex = match cached { + Some((k, tex)) if k == key => tex, + _ => { + let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() }; + let img = app.session.render(cid, t, opts); + let tex = ctx.load_texture(format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + let b = comp.background; + p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); + p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + if share && app.ui.viewer.safe_margins { + for k in [0.9, 0.8] { + p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); + } + } + let label = if comp.has_3d() { v.label() } else { "Active Camera" }; + p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); + app.auto.add(&format!("viewer.view.{}", v.id()), r, label); + } + main +} + fn checker(p: &egui::Painter, r: Rect) { let s = 10.0; p.rect_filled(r, 0.0, Color32::from_gray(0xcc)); @@ -262,8 +338,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Bottom control bar. let bar_h = 30.0; let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max); - let area = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y)); + let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y)); let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard); + // View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which + // keeps the overlays and the interaction. + let area = aux_views(app, ui, &comp, cid, full, pasteboard); p.rect_filled(area, 0.0, pasteboard); let (cw, ch) = (comp.width as f32, comp.height as f32); @@ -405,7 +484,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { let (m, _) = l2c(&ectx, l); for g in masks.groups() { - let GroupKind::Mask { color, .. } = g.kind else { continue }; + let GroupKind::Mask { color, locked, .. } = g.kind else { continue }; + if locked && app.session.state.hide_locked_masks { + continue; + } let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue }; let Some(sp) = path.as_path() else { continue }; let k = effectcraft_engine::render::kurbo_path(sp); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c14fbef..d5a5a2cc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -325,6 +325,9 @@ pub struct UiState { pub slot_tools: Vec, pub workspace: String, pub dock: DockNode, + /// Saved workspace layouts (Save Changes to this Workspace / Save as New Workspace). + #[serde(default)] + pub saved_workspaces: std::collections::BTreeMap, pub focused: PanelKind, pub viewer: ViewerState, pub timeline: TimelineState, @@ -372,6 +375,7 @@ impl Default for UiState { slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(), workspace: "Default".into(), dock: crate::dock::workspace("Default"), + saved_workspaces: Default::default(), focused: PanelKind::Composition, viewer: ViewerState::default(), timeline: TimelineState::default(),