mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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}"),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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]));
|
||||
|
||||
Reference in New Issue
Block a user