Merge branch 'main' into worktree-agent-a15cecfec57d5290d

# Conflicts:
#	crates/effects/src/ocio.rs
#	crates/engine/src/commands/stubs.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-03 02:26:19 -04:00
151 changed files with 14085 additions and 2190 deletions
+5 -2
View File
@@ -87,6 +87,9 @@ fn apply_settings(c: &mut Comp, p: &Value) {
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
@@ -249,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@@ -258,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
has_comp,
settings
),
+1 -1
View File
@@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
save_to(s, &next)
}
+105
View File
@@ -0,0 +1,105 @@
//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to
//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance
//! to Layer (a one-off value).
use effectcraft_geom::vec3;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemId, LayerId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer).
fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let sel: Vec<LayerId> = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect();
let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera());
let cam = match p.get("camera") {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?,
};
if !is_cam(comp, cam) {
return Err(bad(cmd, "`camera` is not a camera layer"));
}
let target = match p.get("layer") {
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?),
None => sel.iter().copied().find(|l| *l != cam),
};
if need_target && target.is_none() {
return Err(bad(cmd, "select the camera and the layer to focus on"));
}
Ok((cid, cam, target))
}
fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result<Value> {
s.edit(label, None, |proj, _| {
let l = layer_mut(proj, cid, cam)?;
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?;
pr.expr = Some(Expression { text: text.clone(), enabled: true });
Ok(json!({"expression": text}))
})
}
fn link_poi(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?;
set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into())
}
/// The focus expression for a target layer: its anchor point's distance along the camera's view
/// axis.
fn layer_expr(name: &str) -> String {
let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
format!(
"var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)"
)
}
fn link_layer(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?;
let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?;
set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name))
}
/// Camera depth of `target`'s anchor point at the current time.
pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option<f64> {
let comp = s.project.comp(cid)?;
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
ctx.expr = s.expr.as_deref();
let c = ctx.comp.layer(cam)?;
let t = ctx.comp.layer(target)?;
let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c);
let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2]))))
}
fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
let c = "camera.setFocusToLayer";
let (cid, cam, target) = refs(s, p, c, true)?;
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
if d <= 0.0 {
return Err(bad(c, "the layer is behind the camera"));
}
let t = s.time();
s.edit("Set Focus Distance to Layer", None, |proj, _| {
let l = layer_mut(proj, cid, cam)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?;
pr.set_value_at(lt, KV::Scalar(d));
Ok(json!({"focusDistance": d}))
})
}
fn camera_selected(s: &Session) -> std::result::Result<(), String> {
let c = s.active_comp().ok_or("no composition is open")?;
if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi),
cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer),
cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer),
]
}
+1
View File
@@ -16,6 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
// Application.
fe!("app.about", "About EffectCraft...", [], None, "{}", always),
fe!("layer.style.options", "Layer Style Options...", ["Layer", "Layer Styles"], None, "{layer?, style?: blendingOptions|dropShadow|…}", has_layers),
fe!(
"app.settings",
"Settings...",
+85
View File
@@ -0,0 +1,85 @@
//! Keyframe color labels: Label a keyframe (Keyframe ▸ Label ▸ colour) and Edit ▸ Select Keyframe
//! Label Group (On Selected Layers / On All Layers / Visible Keyframes on …).
use effectcraft_color::Label;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_comp, has_keys, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
fn label_p(p: &Value, c: &str) -> Result<u8> {
match p.get("label") {
Some(Value::String(s)) => {
let l = if s.eq_ignore_ascii_case("none") { Some(Label::None) } else { Label::from_name(s) };
let l = l.ok_or_else(|| bad(c, format!("unknown label `{s}`")))?;
Ok(Label::ALL.iter().position(|x| *x == l).unwrap_or(0) as u8)
}
Some(Value::Number(n)) => Ok(n.as_u64().filter(|i| (*i as usize) < Label::ALL.len()).ok_or_else(|| bad(c, "label index 0–16"))? as u8),
_ => Err(bad(c, "missing `label` (name or 0–16)")),
}
}
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
let c = "keys.setLabel";
let label = label_p(p, c)?;
let n = super::prop::edit_keys(s, "Keyframe Label", None, |keys, i, _| {
keys[i].label = label;
true
})?;
Ok(json!({"label": Label::ALL[label as usize].name(), "keys": n}))
}
/// Select every keyframe that shares a label with a selected keyframe, on the selected or all
/// layers. `visible` (property uids revealed in the Timeline) limits the "Visible Keyframes"
/// scopes; without it every property counts as visible.
fn select_label_group(s: &mut Session, p: &Value) -> Result<Value> {
let c = "keys.selectLabelGroup";
let scope = str_p(p, "scope").unwrap_or("selected");
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let labels: Vec<u8> =
s.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer)?.props.find(k.prop)?.keys.iter().find(|x| x.time == k.time).map(|x| x.label)).collect();
if labels.is_empty() {
return Err(bad(c, "select a keyframe first"));
}
let only_selected = match scope {
"selected" | "visibleSelected" => true,
"all" | "visibleAll" => false,
_ => return Err(bad(c, "scope: selected|all|visibleSelected|visibleAll")),
};
let visible: Option<Vec<u64>> =
(scope.starts_with("visible")).then(|| p.get("visible").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())).flatten();
let key_layers: Vec<_> = s.state.selected_keys.iter().map(|k| k.layer).collect();
let mut sel = Vec::new();
for l in &comp.layers {
if only_selected && !s.state.selected_layers.contains(&l.id) && !key_layers.contains(&l.id) {
continue;
}
l.props.walk("", &mut |_, pr| {
if visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) {
return;
}
for k in &pr.keys {
if labels.contains(&k.label) {
sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time });
}
}
});
}
s.state.selected_keys = sel;
Ok(json!({"keys": s.state.selected_keys.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.setLabel", "Keyframe Label", [], None, "{label: name|none|0–16}", has_keys, set_label),
cmd!(
"keys.selectLabelGroup",
"Select Keyframe Label Group",
[],
None,
"{scope: selected|all|visibleSelected|visibleAll, visible?: [prop uid]}",
has_comp,
select_label_group
),
]
}
+213 -1
View File
@@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
let mut outs = pts(p.get("outTangents"));
ins.resize(n, [0.0; 2]);
outs.resize(n, [0.0; 2]);
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let uid = s.edit("New Mask", None, |proj, st| {
let mut next = proj.next_id;
@@ -114,6 +114,7 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
sp.vertices.insert(i, point);
sp.in_tangents.insert(i, tin);
sp.out_tangents.insert(i, tout);
sp.feather_after_insert(i, None);
Ok(i)
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
@@ -241,6 +242,7 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
sp.vertices.remove(i);
sp.in_tangents.remove(i);
sp.out_tangents.remove(i);
sp.feather_after_remove(i);
}
}
if sp.vertices.len() < 3 {
@@ -324,9 +326,199 @@ pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<us
sp.vertices.insert(at, m);
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
sp.feather_after_insert(at, Some(t));
Some(at)
}
/// Nearest (segment, t, distance) of a path to a point (layer space), sampled per segment.
pub(crate) fn nearest_on_path(sp: &ShapePath, q: [f64; 2]) -> Option<(usize, f64, f64)> {
let n = sp.vertices.len();
let mut best: Option<(usize, f64, f64)> = None;
for seg in 0..sp.segment_count() {
let j = (seg + 1) % n;
let p0 = sp.vertices[seg];
let p3 = sp.vertices[j];
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
let at = |t: f64| {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
[a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]
};
let dist = |t: f64| {
let p = at(t);
(p[0] - q[0]).hypot(p[1] - q[1])
};
let (mut bt, mut bd) = (0.0, f64::INFINITY);
for k in 0..=64 {
let t = k as f64 / 64.0;
let d = dist(t);
if d < bd {
(bt, bd) = (t, d);
}
}
// Refine by bisection-style narrowing.
let mut step = 1.0 / 64.0;
for _ in 0..20 {
step *= 0.5;
for t in [bt - step, bt + step] {
let t = t.clamp(0.0, 1.0);
let d = dist(t);
if d < bd {
(bt, bd) = (t, d);
}
}
}
if best.is_none_or(|b| bd < b.2) {
best = Some((seg, bt, bd));
}
}
best
}
/// Whether a layer-space point is inside a closed path.
fn inside_path(sp: &ShapePath, q: [f64; 2]) -> bool {
sp.closed && {
let cov = effectcraft_path::fill_coverage(
&[effectcraft_path::to_kurbo(sp)],
&effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-q[0] + 0.5, -q[1] + 0.5)),
1,
1,
effectcraft_path::FillRule::NonZero,
);
cov.data[0] >= 0.5
}
}
/// Point of segment `seg` at parameter `t`.
fn point_on(sp: &ShapePath, seg: usize, t: f64) -> Option<[f64; 2]> {
let n = sp.vertices.len();
if seg >= sp.segment_count() {
return None;
}
let j = (seg + 1) % n;
let (p0, p3) = (sp.vertices[seg], sp.vertices[j]);
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Some([a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]])
}
fn feather_index(p: &Value, sp: &ShapePath, c: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (feather point, 0-based)"))? as usize;
if i >= sp.feather.len() {
return Err(bad(c, format!("no feather point {i} (the mask has {})", sp.feather.len())));
}
Ok(i)
}
/// Mask Feather tool: add a feather point on the path, by `segment`/`t` or at the path position
/// nearest `point` (layer space). Without `radius`, a `point` off the path sets it from its
/// distance (outside the mask = outer feather, inside = inner).
fn feather_add(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.add";
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
let q = pt(p.get("point"));
let radius = super::f_p(p, "radius");
let tension = super::f_p(p, "tension").unwrap_or(0.0).clamp(0.0, 100.0) / 100.0;
let (uid, i) = edit_path(s, p, c, |sp| {
let (seg, t, r) = match (seg, q) {
(Some(seg), _) => {
if seg >= sp.segment_count() {
return Err(bad(c, format!("no segment {seg}")));
}
(seg, t.unwrap_or(0.5), radius.unwrap_or(0.0))
}
(None, Some(q)) => {
let (seg, t, d) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
(seg, t, radius.unwrap_or(if inside_path(sp, q) { -d } else { d }))
}
(None, None) => return Err(bad(c, "give `segment` (and `t`) or `point`")),
};
sp.feather.push(effectcraft_keyframe::FeatherPoint { segment: seg, t, radius: r, tension });
Ok(sp.feather.len() - 1)
})?;
Ok(json!({"mask": uid, "index": i}))
}
/// Drag a feather point: move it along the path (`segment`/`t`, or nearest `point`) and/or set
/// its `radius` (signed: negative = inner) and `tension` (%).
fn feather_set(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.set";
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
let q = pt(p.get("point"));
let radius = super::f_p(p, "radius");
let tension = super::f_p(p, "tension").map(|v| v.clamp(0.0, 100.0) / 100.0);
let toward = pt(p.get("toward"));
let (_, out) = edit_path(s, p, c, |sp| {
let i = feather_index(p, sp, c)?;
if let Some(q) = q {
let (sg, tt, _) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
sp.feather[i].segment = sg;
sp.feather[i].t = tt;
}
if let Some(sg) = seg {
if sg >= sp.segment_count() {
return Err(bad(c, format!("no segment {sg}")));
}
sp.feather[i].segment = sg;
}
if let Some(t) = t {
sp.feather[i].t = t;
}
if let Some(r) = radius {
sp.feather[i].radius = r;
}
// Dragging the radius handle: the distance from the point's path position to `toward`,
// outer outside the mask, inner inside.
if let Some(w) = toward {
let f = sp.feather[i];
let at = point_on(sp, f.segment, f.t).ok_or_else(|| bad(c, "the feather point is off the path"))?;
let d = (w[0] - at[0]).hypot(w[1] - at[1]);
sp.feather[i].radius = if inside_path(sp, w) { -d } else { d };
}
if let Some(k) = tension {
sp.feather[i].tension = k;
}
Ok(serde_json::to_value(sp.feather[i]).unwrap_or(Value::Null))
})?;
Ok(out)
}
fn feather_remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.remove";
let all = super::b_p(p, "all").unwrap_or(false);
let (_, n) = edit_path(s, p, c, |sp| {
if all {
sp.feather.clear();
} else {
let i = feather_index(p, sp, c)?;
sp.feather.remove(i);
}
Ok(sp.feather.len())
})?;
Ok(json!({"remaining": n}))
}
fn feather_list(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.list";
let (cid, lid) = layer_p(s, p, c)?;
let key = p.get("mask").cloned().ok_or_else(|| bad(c, "missing `mask`"))?;
let t = s.time();
let mut props = s.project.comp(cid).and_then(|cc| cc.layer(lid)).ok_or(EngineError::NoComp)?.props.clone();
let lt = s.project.comp(cid).and_then(|cc| cc.layer(lid)).map(|l| l.layer_time(t)).unwrap_or(t);
let g = path_group(&mut props, &key).ok_or_else(|| bad(c, "no such mask"))?;
let Some(KV::Path(sp)) = g.get("path").map(|pr| pr.value_at(lt)) else { return Err(bad(c, "mask has no path")) };
Ok(
json!({"points": sp.feather.iter().map(|f| json!({"segment": f.segment, "t": f.t, "radius": f.radius, "tension": f.tension * 100.0})).collect::<Vec<_>>()}),
)
}
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let seg =
@@ -370,6 +562,26 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
cmd!(
"mask.featherPoint.add",
"Add Mask Feather Point",
[],
None,
"{layer?, mask, segment?, t?: 0..1, point?: [x,y] (layer space), radius? (px, negative = inner), tension? (%)} → {index}",
has_comp,
feather_add
),
cmd!(
"mask.featherPoint.set",
"Move Mask Feather Point",
[],
None,
"{layer?, mask, index, segment?, t?, point? (slide along the path), radius?, toward?: [x,y] (radius from the drag point, layer space), tension? (%), merge?}",
has_comp,
feather_set
),
cmd!("mask.featherPoint.remove", "Delete Mask Feather Point", [], None, "{layer?, mask, index? | all?}", has_comp, feather_remove),
crate::query!("mask.featherPoint.list", "Mask Feather Points", "{layer?, mask}", feather_list),
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
]
+6
View File
@@ -14,8 +14,10 @@ pub mod essential;
pub mod expr_tools;
mod file;
pub(crate) mod file_more;
mod focus;
mod frontend;
mod help;
mod key_labels;
mod key_transform;
mod layer;
mod layer_menu;
@@ -39,6 +41,7 @@ mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod settings;
mod shape_stroke;
mod stubs;
mod styles;
mod text_anim;
@@ -135,6 +138,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(focus::specs());
v.extend(key_labels::specs());
v.extend(key_transform::specs());
v.extend(file_more::specs());
v.extend(lottie::specs());
+1 -1
View File
@@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
+221
View File
@@ -0,0 +1,221 @@
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
fn is_stroke(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "stroke" | "gstroke")
}
/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a
/// selected stroke property wins).
fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if let Some(uid) = p.get("prop").and_then(Value::as_u64) {
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
if !is_stroke(g) {
return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name)));
}
return Ok((cid, lid, uid));
}
let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?;
// A selected stroke (or a property inside one), else the first stroke in the contents.
let mut found = None;
for (sl, su) in &s.state.selected_props {
if *sl == lid
&& let Some(g) = find_stroke_containing(contents, *su)
{
found = Some(g);
break;
}
}
if found.is_none() {
let mut first = None;
walk_groups(contents, &mut |g| {
if first.is_none() && is_stroke(g) {
first = Some(g.uid);
}
});
found = first;
}
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke"))
}
fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) {
for sub in g.groups() {
f(sub);
walk_groups(sub, f);
}
}
fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option<Uid> {
let mut out = None;
walk_groups(contents, &mut |g| {
if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) {
out = Some(g.uid);
}
});
out
}
fn dashes_add(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.add";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Add Dash", None, |proj, _| {
let mut next = proj.next_id;
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
// Before the first "+", Dash is 0 (no dashes): the first "+" turns it on.
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
let mut ids = Ids(&mut next);
let added = if dash <= 0.0 {
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(10.0);
}
"dash"
} else {
let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else {
return Err(bad(c, "a stroke has at most three dash/gap pairs"));
};
let k = if *dm == "dash2" { 2 } else { 3 };
// Insert before Offset, which stays last.
let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len());
let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0);
let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv);
d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
dm
};
proj.next_id = next;
Ok(json!({"added": added}))
})
}
fn dashes_remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.remove";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Remove Dash", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
for (dm, gm) in EXTRA_DASHES.iter().rev() {
if d.get(dm).is_some() {
d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm);
return Ok(json!({"removed": dm}));
}
}
// The last "−" turns dashes off.
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(0.0);
p.keys.clear();
}
Ok(json!({"removed": "dash"}))
})
}
/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group.
fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result<Value> {
let (cid, lid, uid) = stroke_ref(s, p, c)?;
let t = s.time();
let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" };
s.edit(label, merge_p(p), |proj, _| {
let mut next = proj.next_id;
{
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
if g.sub(group).is_none() {
let mut ids = Ids(&mut next);
let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) };
g.children.push(ng.into());
}
}
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?;
let mut out = serde_json::Map::new();
for (param, m, is_enum) in fields {
let Some(v) = p.get(*param) else { continue };
let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?;
let nv = if *is_enum {
let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] };
let i = match v {
Value::String(s) => {
opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32
}
Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32,
_ => return Err(bad(c, format!("bad `{param}`"))),
};
KV::Enum(i)
} else {
let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?;
let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) };
KV::Scalar(x)
};
pr.set_value_at(lt, nv.clone());
out.insert(param.to_string(), nv.to_json());
}
Ok(json!({"stroke": uid, group: Value::Object(out)}))
})
}
fn taper(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.taper",
"taper",
&[
("units", "units", true),
("startLength", "startLength", false),
("endLength", "endLength", false),
("startWidth", "startWidth", false),
("endWidth", "endWidth", false),
("startEase", "startEase", false),
("endEase", "endEase", false),
],
)
}
fn wave(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.wave",
"wave",
&[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)],
)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
cmd!(
"shape.stroke.taper",
"Stroke Taper",
[],
None,
"{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}",
has_layers,
taper
),
cmd!(
"shape.stroke.wave",
"Stroke Wave",
[],
None,
"{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}",
has_layers,
wave
),
]
}
-5
View File
@@ -17,7 +17,6 @@ macro_rules! stub {
pub fn specs() -> Vec<CommandSpec> {
vec![
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
@@ -26,13 +25,9 @@ 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.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
// Keyframes / text / tracking.
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]
+63
View File
@@ -372,6 +372,60 @@ fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
}
/// Animation ▸ Animate Text ▸ Variable Font Axes: the axes of the layer's font, or (with `axis`)
/// a new animator — or a property on `animator` — that offsets that axis (user units, weighted
/// by the selectors, added to the axis default).
fn font_axes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.animatorFontAxes";
let (cid, lid) = layer_p(s, p, C)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
let Some(KV::Text(doc)) = layer.props.prop("text/sourceText").map(|pr| pr.value_at(layer.layer_time(s.time()))) else {
return Err(bad(C, "not a text layer"));
};
let face = effectcraft_text::layout_doc(&doc).glyphs.first().map(|g| g.face).unwrap_or_else(|| effectcraft_text::resolve(&doc.font, &doc.style).face);
let axes = effectcraft_text::variable::font_axes(face);
let list = || json!(axes.iter().map(|a| json!({"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max})).collect::<Vec<_>>());
let Some(want) = str_p(p, "axis") else { return Ok(json!({"font": doc.font, "axes": list()})) };
if axes.is_empty() {
return Err(bad(C, format!("{} {} has no variation axes (not a variable font)", doc.font, doc.style)));
}
let axis = axes
.iter()
.find(|a| a.tag == want || a.name.eq_ignore_ascii_case(want))
.ok_or_else(|| bad(C, format!("no axis `{want}`; the font has {}", list())))?
.clone();
let target = match p.get("animator") {
Some(_) => Some(animator_uid(s, &layer, p, C)?),
None => None,
};
let uid = s.edit("Add Variable Font Axis", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
let prop = ids
.prop(&format!("{}{}", effectcraft_render::text::AXIS_PREFIX, axis.tag.trim_end()), &axis.name, KV::Scalar(0.0))
.with_ui(effectcraft_project::ParamUi::Number);
let l = layer_mut(proj, cid, lid)?;
let uid = match target {
Some(a) => {
let g = l.props.find_group_mut(a).and_then(|g| g.sub_mut("properties")).ok_or_else(|| bad(C, "no such animator"))?;
g.children.push(prop.into());
a
}
None => {
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
let name = format!("Animator {}", anims.children.len() + 1);
let g = build::text_animator(&mut ids, &name, vec![prop]);
let uid = g.uid;
anims.children.push(g.into());
uid
}
};
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"animator": uid, "axis": axis.tag}))
}
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
}
@@ -415,6 +469,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_layer,
apply_preset
),
cmd!(
"text.animatorFontAxes",
"Variable Font Axes",
["Animation", "Animate Text"],
None,
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
has_text_layer,
font_axes
),
query!("text.presets", "Text Animation Presets", "{}", list_presets),
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
]
+1 -1
View File
@@ -254,7 +254,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
*t = [r.x, r.y];
}
}
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));