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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:16:42 -04:00
parent c4100bcf80
commit 799b5b39ed
26 changed files with 1546 additions and 68 deletions

View File

@ -68,6 +68,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{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),

View File

@ -256,7 +256,7 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// 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![];

View File

@ -44,9 +44,13 @@ fn edit_path<T>(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(())

View File

@ -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());

View File

@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<VertexRef> = 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<usize> = {
let sel: Vec<usize> =
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<Value> {
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<Uid> = 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<PropGroup> = 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<Value> {
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<Node> = 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<CommandSpec> {
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),
]
}

View File

@ -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<bool> {
Ok(any)
}
/// `module` (1-based; 1 = the first output module).
fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result<usize> {
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<Value> = 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<Value> {
fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>()}))
}
/// `item?|index?`, else the last item (the queue's newest entry).
fn item_or_last(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
vec![
cmd!(
@ -405,7 +520,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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),

View File

@ -21,9 +21,6 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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}"),
]
}

View File

@ -529,6 +529,122 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec3> {
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<Value> {
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<LayerId> = 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<Value> {
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, &params, 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<CommandSpec> {
vec![
cmd!(
@ -596,6 +712,11 @@ pub fn specs() -> Vec<CommandSpec> {
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!(

View File

@ -75,12 +75,28 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.region_of_interest))
}
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<CommandSpec> {
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),
]
}

View File

@ -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<ItemId, effectcraft_render::three_d::Views3D>,
/// 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;

View File

@ -151,6 +151,21 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
})
}
"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,
}
}

View File

@ -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<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
exporter: Arc<dyn Exporter>,
items: Vec<(RenderQueueItem, String)>,
/// Per queue item: one (item with that output module, resolved path) per output module.
items: Vec<Vec<(RenderQueueItem, String)>>,
}
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<ExportResult, String> = 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<u64> = items.iter().map(|(i, _)| i.id).collect();
let ids: Vec<u64> = 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<Option<ItemId>, 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<ItemId> = 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<ItemId> {
if p.get("comp").is_some() {

View File

@ -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}");
}

View File

@ -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<u64> = 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<u8>)| 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::<Vec<_>>();
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<Vec<String>>,
}
impl Exporter for MockExporter {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
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<Footage, String> {
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<MockExporter>) {
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}");
}
}

View File

@ -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)))

View File

@ -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;

View File

@ -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,

View File

@ -401,6 +401,37 @@ pub struct RenderQueueItem {
/// Resolved path of the last render (first file for sequences).
#[serde(default)]
pub last_output: Option<String>,
/// 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<OutputModule>,
/// 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<Item = &OutputModule> {
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)

View File

@ -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

View File

@ -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<Mask> {
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<Mask> = 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<Mask> {
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)

View File

@ -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();
}
}

View File

@ -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<String> {
let mut v: Vec<String> = 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) {

View File

@ -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<f64> = 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<crate::panels::viewer::ViewerMap> = 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<Res
"view.importGuides" => ("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")),

View File

@ -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());

View File

@ -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);

View File

@ -325,6 +325,9 @@ pub struct UiState {
pub slot_tools: Vec<Tool>,
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<String, DockNode>,
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(),