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:
parent
c4100bcf80
commit
799b5b39ed
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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![];
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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!(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue