Merge branch 'worktree-agent-a2255b65f530fbf80'

This commit is contained in:
Brandon Thomas 2026-10-03 00:44:59 -04:00
commit afd69fb97c
66 changed files with 4019 additions and 128 deletions

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

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),
]
}

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...",

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
),
]
}

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)),
]

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;
@ -38,6 +40,7 @@ mod query;
mod render_queue;
pub mod roto_cmds;
mod settings;
mod shape_stroke;
mod stubs;
mod styles;
mod text_anim;
@ -133,6 +136,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());

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
),
]
}

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).
@ -29,9 +28,6 @@ pub fn specs() -> Vec<CommandSpec> {
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"], "{}"),
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"], "{}"),
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
@ -43,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
// 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}"),
]

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),
]

View File

@ -224,7 +224,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]));

View File

@ -717,6 +717,8 @@ mod tests_stubs;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_tail;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
mod tests_text_edit;

View File

@ -105,6 +105,7 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
closed: p.closed,
feather: p.feather.clone(),
}
}

View File

@ -677,6 +677,8 @@ Layer
Matte with Layer Above | layer.trackMatte {"op":"above"}
Matte with Layer Below | layer.trackMatte {"op":"below"}
Layer Styles
Layer Style Options... | layer.style.options
---
Convert to Editable Styles | layer.style.convertToEditable
Show All | layer.style.showAll
Remove All | layer.style.removeAll

View File

@ -207,7 +207,7 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
let mut ids = Ids(&mut proj.next_id);
for (k, sp) in vm.subpaths.iter().enumerate() {
let n = sp.knots.len();
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed };
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed, feather: Vec::new() };
for kn in &sp.knots {
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
path.vertices.push(v);

View File

@ -0,0 +1,357 @@
//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo,
//! serde).
use serde_json::{Value, json};
use crate::Session;
use effectcraft_project::{LayerId, Project};
pub(crate) fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap();
s
}
pub(crate) fn roundtrip(s: &Session) -> Project {
Project::from_json(&s.project.to_json()).unwrap()
}
/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill.
fn line_layer(s: &mut Session) -> (u64, u64) {
let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid;
(lid, stroke)
}
fn column(s: &Session, x: u32) -> f32 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
(0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum()
}
#[test]
fn stroke_taper_wave_commands_render_undo_and_serde() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
assert!((column(&s, 30) - 10.0).abs() < 0.6);
let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap();
assert_eq!(r["stroke"], json!(stroke));
assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30));
// Percent units.
s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke");
s.execute("edit.redo", json!({})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8);
// Wave in cycles.
s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap();
assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120));
// Values are ordinary animatable properties that survive save/load.
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
let g = l.props.find_group(stroke).unwrap();
assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0);
assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0);
assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err());
}
#[test]
fn dashes_plus_minus_buttons() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string();
assert_eq!(add(&mut s), "dash");
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap();
assert_eq!(add(&mut s), "dash2");
assert_eq!(add(&mut s), "dash3");
assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most");
let names = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::<Vec<_>>()
};
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]);
// Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5.
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap();
s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap();
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]);
// Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps).
assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s).len(), 7);
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0);
}
fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid)
}
/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150).
fn masked_solid(s: &mut Session) -> (u64, u64) {
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap();
(l, r["mask"].as_u64().unwrap())
}
fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image {
let cid = s.active_comp_id().unwrap();
s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default())
}
fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 {
let img = render_at(s, t);
img.data[(y * img.width + x) as usize][3]
}
#[test]
fn variable_mask_feather_points() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
assert!(alpha_at(&s, 100, 45, 0.0) < 0.01);
// Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px.
let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap();
assert_eq!(r["index"], json!(0));
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
let p0 = &pts["points"][0];
assert_eq!(p0["segment"], json!(0));
assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}");
let soft = alpha_at(&s, 100, 39, 0.0);
assert!((soft - 0.475).abs() < 0.06, "{soft}");
// An inner point dragged inside the bottom edge.
s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}");
assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0));
assert!(alpha_at(&s, 100, 152, 0.0) < 0.01);
// Drag the radius; undo; redo.
s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) > 0.7);
s.execute("edit.undo", json!({})).unwrap();
assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08);
s.execute("edit.redo", json!({})).unwrap();
// Inserting a vertex keeps the points where they were on the path.
let before = alpha_at(&s, 100, 39, 0.0);
s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert_eq!(pts["points"][0]["segment"], json!(1));
assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6);
assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02);
// Serde: the points travel in the Mask Path value.
let back = roundtrip(&s);
let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap();
let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() };
assert_eq!(sp.feather.len(), 2);
s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) < 0.01);
assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err());
}
#[test]
fn mask_motion_blur_ramps_across_the_motion() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).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();
// 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps
// 165…166, so pixel 165 is half covered.
let verts: Vec<Value> = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect();
s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap();
let a = alpha_at(&s, 165, 100, 0.5);
assert!(a > 0.3 && a < 0.7, "{a}");
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
let hard = alpha_at(&s, 165, 100, 0.5);
assert!(!(0.3..0.7).contains(&hard), "{hard}");
}
#[test]
fn adaptive_sample_limit_drives_layer_samples() {
assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16);
assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256);
let mut s = comp(400, 100);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
// 100 px per frame (keys 3 frames apart, 300 px).
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap();
let max_step = |s: &Session| {
let img = render_at(s, 1.0 / 30.0);
let row: Vec<f32> = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect();
row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max)
};
s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap();
let coarse = max_step(&s);
s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap();
let fine = max_step(&s);
assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}");
assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}");
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16);
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16);
// Older projects without the field read the After Effects default.
fn strip(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("motion_blur_adaptive_limit");
o.values_mut().for_each(strip);
}
Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap();
strip(&mut v);
let json = v.to_string();
assert!(!json.contains("motion_blur_adaptive_limit"));
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
}
#[test]
fn keyframe_label_groups() {
let mut s = comp(200, 100);
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
for l in [a, b] {
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap();
}
}
let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
let (ua, ub) = (uid(&s, a), uid(&s, b));
// Label a's first key and b's last key Blue.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Green"})).unwrap();
// On selected layers: from a's first key, only a's Blue key.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1));
// On all layers: both Blue keys.
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2));
assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6));
// Visible keyframes: only the revealed properties count.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1));
// Unlabelled keys group together too (label None).
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3));
// Undo, serde.
let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label;
assert_eq!(lab(&s), 9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(lab(&s), 0);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9);
assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err());
}
#[test]
fn tate_chu_yoko_via_set_text_with_undo_and_serde() {
let mut s = comp(400, 400);
let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap();
s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap();
let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko);
let lay = effectcraft_text::layout_doc(&doc);
assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row");
let back = roundtrip(&s);
let effectcraft_keyframe::Value::Text(d) =
&back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
else {
panic!()
};
assert!(d.style_at(1).tate_chu_yoko);
s.execute("edit.undo", json!({})).unwrap();
assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko);
s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright);
}
#[test]
fn variable_font_axes_animator() {
let mut s = comp(400, 200);
let t = s.execute("layer.newText", json!({"text": "HH", "size": 60})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [t]})).unwrap();
assert!(s.is_enabled("text.animatorFontAxes"));
// Inter (bundled) is static.
assert_eq!(s.execute("text.animatorFontAxes", json!({})).unwrap()["axes"], json!([]));
assert!(s.execute("text.animatorFontAxes", json!({"axis": "wght"})).is_err());
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
eprintln!("no variable font installed: animation check skipped");
return;
};
let info = effectcraft_text::fonts::face(face).info.clone();
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
let axis = axes.iter().find(|a| a.max > a.min).unwrap().clone();
let listed = s.execute("text.animatorFontAxes", json!({})).unwrap();
if listed["axes"].as_array().is_none_or(|a| a.is_empty()) {
eprintln!("the variable face isn't reachable by family/style: skipped");
return;
}
let tag = listed["axes"][0]["tag"].as_str().unwrap().to_string();
let r = s.execute("text.animatorFontAxes", json!({"axis": tag})).unwrap();
let anim = r["animator"].as_u64().unwrap();
let area = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
};
let before = area(&s);
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
let range = (axis.max - axis.min) as f64;
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
let after = area(&s);
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
s.execute("edit.undo", json!({})).unwrap();
assert!((area(&s) - before).abs() < 1e-6);
let _ = info;
}
#[test]
fn camera_focus_commands_undo_and_serde() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
assert!(s.is_enabled("camera.setFocusToLayer"));
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
let back = roundtrip(&s);
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
s.execute("edit.undo", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].is_null());
s.execute("edit.undo", json!({})).unwrap();
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((v - before).abs() < 1e-9);
s.execute("edit.redo", json!({})).unwrap();
// A camera alone can't link to a layer, but can link to its point of interest.
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}

View File

@ -292,6 +292,7 @@ fn split_segment_keeps_curve_shape() {
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
closed: false,
feather: Vec::new(),
};
let seg = |sp: &ShapePath, i: usize, t: f64| {
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);

View File

@ -482,7 +482,13 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
v.resize(n, [0.0; 2]);
v
};
Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed })
Value::Path(effectcraft_keyframe::ShapePath {
vertices: points.clone(),
in_tangents: fit(ins),
out_tangents: fit(outs),
closed: *closed,
feather: Vec::new(),
})
}
_ => return Err("Error: expression result must be a path".into()),
},

View File

@ -66,6 +66,50 @@ pub fn session() -> Session {
mod tests {
use serde_json::json;
/// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions
/// evaluate to the camera's focus on the target.
#[test]
fn focus_link_expressions_track_the_target() {
use effectcraft_engine::geom::vec3;
let mut s = super::session();
s.execute("comp.new", json!({"name": "F", "width": 400, "height": 300, "duration": 2})).unwrap();
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [260, 120, 400]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let focus = |s: &effectcraft_engine::Session| {
let comp = s.project.comp(cid).unwrap();
let mut ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
ctx.expr = s.expr.as_deref();
let l = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
ctx.value(l, l.props.prop("cameraOptions/focusDistance").unwrap()).as_f64()
};
let depth = |s: &effectcraft_engine::Session| {
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
let c = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
let tl = comp.layer(effectcraft_engine::project::LayerId(t)).unwrap();
let cs = effectcraft_engine::render::three_d::camera::layer_camera(&ctx, c);
cs.depth(ctx.world_matrix(tl).apply(vec3(25.0, 25.0, 0.0)))
};
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
let set = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((set - depth(&s)).abs() < 1e-6 && (focus(&s) - set).abs() < 1e-6);
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
// Move the target: the linked focus follows.
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 900]})).unwrap();
assert!((focus(&s) - depth(&s)).abs() < 1e-3, "{} vs {}", focus(&s), depth(&s));
s.execute("camera.linkFocusToPoi", json!({"camera": cam})).unwrap();
let poi = {
let l = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(cam)).unwrap();
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
let q = l.props.prop("transform/poi").unwrap().value.as_vec3();
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
};
assert!((focus(&s) - poi).abs() < 1e-6, "{} vs {poi}", focus(&s));
}
#[test]
fn wired_session_renders_demo_with_expressions() {
let mut s = super::session();

View File

@ -14,7 +14,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -84,6 +84,13 @@ pub struct Keyframe {
pub spatial_continuous: bool,
#[serde(default)]
pub roving: bool,
/// Keyframe color label: index into the label list (0 = None).
#[serde(default, skip_serializing_if = "is_zero")]
pub label: u8,
}
fn is_zero(v: &u8) -> bool {
*v == 0
}
fn yes() -> bool {
@ -106,6 +113,7 @@ impl Keyframe {
spatial_auto: true,
spatial_continuous: false,
roving: false,
label: 0,
}
}
pub fn hold(mut self) -> Keyframe {

View File

@ -172,6 +172,11 @@ pub struct CharStyle {
pub baseline: BaselineOption,
pub kerning: Kerning,
pub ligatures: bool,
/// Vertical type: set these characters horizontally within the column (Tate-Chu-Yoko).
pub tate_chu_yoko: bool,
/// Vertical type: keep Roman (half-width) characters upright instead of turning them on
/// their side (Character panel menu ▸ Standard Vertical Roman Alignment).
pub vertical_roman_upright: bool,
}
impl Default for CharStyle {
@ -250,6 +255,9 @@ pub struct TextDoc {
pub kerning: Kerning,
#[serde(default = "yes")]
pub ligatures: bool,
/// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]).
pub tate_chu_yoko: bool,
pub vertical_roman_upright: bool,
pub indent_left: f64,
pub indent_right: f64,
pub indent_first: f64,
@ -299,6 +307,8 @@ impl Default for TextDoc {
baseline: BaselineOption::Normal,
kerning: Kerning::Metrics,
ligatures: true,
tate_chu_yoko: false,
vertical_roman_upright: false,
indent_left: 0.0,
indent_right: 0.0,
indent_first: 0.0,
@ -383,6 +393,8 @@ impl TextDoc {
baseline: self.baseline,
kerning: self.kerning,
ligatures: self.ligatures,
tate_chu_yoko: self.tate_chu_yoko,
vertical_roman_upright: self.vertical_roman_upright,
}
}
@ -408,6 +420,8 @@ impl TextDoc {
self.baseline = s.baseline;
self.kerning = s.kerning;
self.ligatures = s.ligatures;
self.tate_chu_yoko = s.tate_chu_yoko;
self.vertical_roman_upright = s.vertical_roman_upright;
}
/// The base (first paragraph's) settings.
@ -792,6 +806,8 @@ pub const CHAR_ATTRS: &[&str] = &[
"subscript",
"kerning",
"ligatures",
"tateChuYoko",
"verticalRomanUpright",
];
/// Paragraph attribute keys of `layer.setText`.
@ -851,6 +867,8 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
}
"kerning" => s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?,
"ligatures" => s.ligatures = flag(key, v)?,
"tateChuYoko" => s.tate_chu_yoko = flag(key, v)?,
"verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?,
_ => return Ok(false),
}
Ok(true)
@ -896,7 +914,7 @@ pub fn char_style_json(s: &CharStyle) -> J {
"tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift,
"hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic,
"allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(),
"ligatures": s.ligatures,
"ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright,
})
}

View File

@ -10,15 +10,79 @@ pub struct ShapePath {
pub in_tangents: Vec<[f64; 2]>,
pub out_tangents: Vec<[f64; 2]>,
pub closed: bool,
/// Variable-width mask feather points (Mask Feather tool). They travel with the path, so a
/// Mask Path keyframe carries them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub feather: Vec<FeatherPoint>,
}
/// A mask feather point: a position on the path (segment `segment`, from vertex `segment` to the
/// next, at Bezier parameter `t`), a signed feather `radius` in pixels (positive = outer
/// feather, negative = inner) and a `tension` 0–1 (0: the radius blends smoothly to the
/// neighbouring points, 1: linearly).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct FeatherPoint {
pub segment: usize,
pub t: f64,
pub radius: f64,
#[serde(default)]
pub tension: f64,
}
impl ShapePath {
pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath {
ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed }
ShapePath {
vertices: points.to_vec(),
in_tangents: vec![[0.0; 2]; points.len()],
out_tangents: vec![[0.0; 2]; points.len()],
closed,
feather: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.vertices.len()
}
/// Number of segments (vertex to next vertex, the closing one included).
pub fn segment_count(&self) -> usize {
let n = self.vertices.len();
if self.closed { n } else { n.saturating_sub(1) }
}
/// Keep feather points in place after a vertex was inserted at `i` (segments from `i` on
/// shift by one; a split of segment `i - 1` at parameter `split` re-parameterises its points).
pub fn feather_after_insert(&mut self, i: usize, split: Option<f64>) {
for f in &mut self.feather {
if i > 0 && f.segment == i - 1 {
if let Some(t) = split.filter(|t| *t > 0.0 && *t < 1.0) {
if f.t < t {
f.t /= t;
} else {
f.segment += 1;
f.t = (f.t - t) / (1.0 - t);
}
}
} else if f.segment >= i {
f.segment += 1;
}
}
}
/// Keep feather points after vertex `i` was removed: its two segments merge.
pub fn feather_after_remove(&mut self, i: usize) {
let segs = self.segment_count();
self.feather.retain_mut(|f| {
if i == 0 && f.segment == 0 && !self.closed {
return false;
}
if i > 0 && f.segment == i - 1 {
f.t *= 0.5;
} else if f.segment == i && i > 0 {
f.segment = i - 1;
f.t = 0.5 + f.t * 0.5;
} else if f.segment > i || (i == 0 && f.segment > 0) {
f.segment -= 1;
}
f.segment < segs.max(1)
});
}
pub fn is_empty(&self) -> bool {
self.vertices.is_empty()
}
@ -36,6 +100,7 @@ impl ShapePath {
in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]],
out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]],
closed: true,
feather: Vec::new(),
}
}
fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath {
@ -48,6 +113,29 @@ impl ShapePath {
in_tangents: l(&self.in_tangents, &o.in_tangents),
out_tangents: l(&self.out_tangents, &o.out_tangents),
closed: self.closed,
// Feather points interpolate when both keys have the same points.
feather: if self.feather.len() == o.feather.len() {
self.feather
.iter()
.zip(&o.feather)
.map(|(a, b)| FeatherPoint {
segment: if t < 1.0 { a.segment } else { b.segment },
t: if a.segment == b.segment {
a.t + (b.t - a.t) * t
} else if t < 1.0 {
a.t
} else {
b.t
},
radius: a.radius + (b.radius - a.radius) * t,
tension: a.tension + (b.tension - a.tension) * t,
})
.collect()
} else if t < 1.0 {
self.feather.clone()
} else {
o.feather.clone()
},
}
}
}

View File

@ -49,6 +49,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
out_tangents: fit(pts("o")),
vertices,
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
feather: Vec::new(),
}))
}
@ -137,11 +138,17 @@ fn stroke_fields(o: &mut Out) {
o.raw("lc", json!(o.e("cap") + 1));
o.raw("lj", json!(o.e("join") + 1));
o.raw("ml", json!(o.f("miter")));
let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9);
let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave"));
if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") {
let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label);
o.ex.warn(msg);
}
if let Some(d) = o.g.sub("dashes") {
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
if dash > 0.0 || d.any_animated() {
let mut list = vec![];
for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] {
for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] {
if let Some(p) = d.get(m) {
let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name));
list.push(json!({"n": n, "nm": p.name, "v": v}));

View File

@ -280,6 +280,7 @@ fn shape_contents_roundtrip() {
in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]],
out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]],
closed: true,
feather: Vec::new(),
},
);
let grad = Gradient {
@ -700,3 +701,27 @@ fn fixture_legacy_keyframes_and_masks() {
assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. }));
assert!(not_blank(&q, c, s(0.9)));
}
#[test]
fn text_style_runs_export_as_range_animators() {
let (mut p, cid) = setup();
let c = comp(&p, cid);
let mut l = build::layer(&mut p, &c, "Runs", LayerSource::Text, (c.width, c.height), None);
let mut doc = TextDoc { text: "Hello".into(), size: 30.0, fill: [1.0, 1.0, 1.0, 1.0], ..Default::default() };
doc.apply_style(1..3, |st| {
st.fill = [1.0, 0.0, 0.0, 1.0];
st.baseline_shift = 6.0;
});
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 60.0, 0.0]);
push(&mut p, cid, l);
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
let a = &json["layers"][0]["t"]["a"][0];
assert_eq!(a["s"]["r"], json!(2), "{a}");
assert_eq!(a["s"]["s"]["k"], json!(1));
assert_eq!(a["s"]["e"]["k"], json!(3));
assert_eq!(a["a"]["fc"]["k"], json!([1.0, 0.0, 0.0, 1.0]));
assert_eq!(a["a"]["p"]["k"], json!([0.0, -6.0, 0.0]));
// Played back as an animator, it looks the same.
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}

View File

@ -171,6 +171,14 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) ->
}
}
let mut animators = vec![];
// Per-character styles: Lottie text documents have one style, so every run that differs
// from the base becomes a text animator whose range selector covers exactly its characters
// (index units). Fonts and faux styles per run can't be expressed.
if let Some(st) = tg.get("sourceText")
&& let Value::Text(d) = &st.value
{
animators.extend(style_run_animators(ex, d, label));
}
if let Some(list) = tg.sub("animators") {
for a in list.groups() {
animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name)));
@ -189,6 +197,62 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) ->
json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}})
}
/// Text animators reproducing the style runs of `d` (fill, stroke, stroke width, size,
/// tracking, baseline shift) over their character ranges.
fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec<Json> {
let base = d.base_style();
let mut out = vec![];
let mut start = 0usize;
let mut warned = false;
for run in d.runs() {
let (a, b) = (start, start + run.len);
start = b;
let s = &run.style;
if *s == base || run.len == 0 {
continue;
}
let k = |v: Json| json!({"a": 0, "k": v});
let mut props = Map::new();
if s.apply_fill && s.fill != base.fill {
props.insert("fc".into(), k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0])));
}
if s.apply_stroke && s.stroke != base.stroke {
props.insert("sc".into(), k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0])));
}
if (s.stroke_width - base.stroke_width).abs() > 1e-9 {
props.insert("sw".into(), k(json!(s.stroke_width - base.stroke_width)));
}
if (s.size - base.size).abs() > 1e-9 && base.size > 0.0 {
let p = s.size / base.size * 100.0;
props.insert("s".into(), k(json!([p, p, 100.0])));
}
if (s.tracking - base.tracking).abs() > 1e-9 {
// Lottie tracking is in 1/1000 em like the Character panel's.
props.insert("t".into(), k(json!(s.tracking - base.tracking)));
}
if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 {
props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0])));
}
let lost = s.font != base.font || s.style != base.style || s.faux_bold != base.faux_bold || s.faux_italic != base.faux_italic;
if lost && !warned {
ex.warn(format!("{label}: per-character fonts and faux styles are not part of Lottie text (base font used)"));
warned = true;
}
if props.is_empty() {
continue;
}
out.push(json!({
"nm": format!("Style {a}–{b}"),
"s": {
"t": 0, "xe": k(json!(0)), "ne": k(json!(0)), "a": k(json!(100)), "b": 1, "rn": 0, "sh": 1,
"r": 2, "m": k(json!(1)), "s": k(json!(a)), "e": k(json!(b)), "o": k(json!(0)),
},
"a": Json::Object(props),
}));
}
out
}
fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json {
let mut props = Map::new();
if let Some(pg) = a.sub("properties") {

268
crates/path/src/feather.rs Normal file
View File

@ -0,0 +1,268 @@
//! Variable-width mask feather (After Effects' Mask Feather tool).
//!
//! Feather points sit on the mask path with a signed radius (outer > 0, inner < 0) and a
//! tension. Between points the outer and inner radii are interpolated along the path's arc
//! length (smoothly at tension 0, linearly at tension 1; closed paths wrap around). Every pixel
//! near the path takes the feather width at its nearest path position: outside the path the
//! opacity falls from 1 at the edge to 0 at the outer radius, inside it rises from 0 at the
//! edge to 1 at the inner radius. Where no radius applies the edge stays hard (anti-aliased).
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{FeatherPoint, ShapePath};
use effectcraft_raster::Mask;
use crate::{FillRule, fill_coverage, to_kurbo};
/// A flattened path in buffer space: points, their arc lengths, and per segment the
/// `(parameter, arc length)` table used to place feather points.
struct Flat {
pts: Vec<[f64; 2]>,
s: Vec<f64>,
seg_tables: Vec<Vec<(f64, f64)>>,
len: f64,
}
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
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]]
}
fn flatten(sp: &ShapePath, m: &Mat3) -> Flat {
let n = sp.vertices.len();
let tr = |p: [f64; 2]| {
let r = m.apply(vec2(p[0], p[1]));
[r.x, r.y]
};
let mut pts: Vec<[f64; 2]> = Vec::new();
let mut s: Vec<f64> = Vec::new();
let mut seg_tables = Vec::new();
let segs = sp.segment_count();
let mut acc = 0.0;
for i in 0..segs {
let j = (i + 1) % n;
let v0 = sp.vertices[i];
let v1 = sp.vertices[j];
let o = sp.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let it = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let (p0, p1, p2, p3) = (tr(v0), tr([v0[0] + o[0], v0[1] + o[1]]), tr([v1[0] + it[0], v1[1] + it[1]]), tr(v1));
let est = ((p1[0] - p0[0]).hypot(p1[1] - p0[1]) + (p2[0] - p1[0]).hypot(p2[1] - p1[1]) + (p3[0] - p2[0]).hypot(p3[1] - p2[1])).max(1.0);
let k = ((est / 1.5).ceil() as usize).clamp(4, 1024);
let mut table = Vec::with_capacity(k + 1);
if pts.is_empty() {
pts.push(p0);
s.push(0.0);
}
table.push((0.0, acc));
let mut prev = p0;
for q in 1..=k {
let t = q as f64 / k as f64;
let p = cubic(p0, p1, p2, p3, t);
acc += (p[0] - prev[0]).hypot(p[1] - prev[1]);
pts.push(p);
s.push(acc);
table.push((t, acc));
prev = p;
}
seg_tables.push(table);
}
Flat { pts, s, seg_tables, len: acc }
}
impl Flat {
/// Arc length of a feather point.
fn arc_of(&self, f: &FeatherPoint) -> Option<f64> {
let table = self.seg_tables.get(f.segment)?;
let t = f.t.clamp(0.0, 1.0);
let i = table.partition_point(|e| e.0 <= t).clamp(1, table.len() - 1);
let (a, b) = (table[i - 1], table[i]);
let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
Some(a.1 + (b.1 - a.1) * u)
}
}
/// Outer / inner feather radius (buffer pixels) along the path, from the feather points.
struct Profile {
/// (arc length, outer radius, inner radius, tension), sorted by arc length.
pts: Vec<(f64, f64, f64, f64)>,
len: f64,
closed: bool,
}
impl Profile {
fn at(&self, u: f64) -> (f64, f64) {
let p = &self.pts;
if p.is_empty() {
return (0.0, 0.0);
}
if p.len() == 1 {
return (p[0].1, p[0].2);
}
let blend = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64), x: f64| {
let x = x.clamp(0.0, 1.0);
let smooth = x * x * (3.0 - 2.0 * x);
let tension = ((a.3 + b.3) * 0.5).clamp(0.0, 1.0);
let w = smooth + (x - smooth) * tension;
(a.1 + (b.1 - a.1) * w, a.2 + (b.2 - a.2) * w)
};
let first = p[0];
let last = p[p.len() - 1];
if u < first.0 || u >= last.0 {
if !self.closed {
return if u < first.0 { (first.1, first.2) } else { (last.1, last.2) };
}
// Wrap from the last point to the first through the path's start.
let span = (self.len - last.0) + first.0;
let x = if u >= last.0 { u - last.0 } else { u + self.len - last.0 };
return blend(&last, &first, if span > 1e-9 { x / span } else { 0.0 });
}
let i = p.partition_point(|e| e.0 <= u).clamp(1, p.len() - 1);
let (a, b) = (&p[i - 1], &p[i]);
let span = b.0 - a.0;
blend(a, b, if span > 1e-9 { (u - a.0) / span } else { 0.0 })
}
}
/// Coverage of a mask path with feather points, transformed by `m` (translate + uniform scale)
/// into a `w × h` mask. `expansion` (layer pixels) grows (>0) or shrinks the mask first;
/// `linear` selects the Linear feather falloff instead of Smooth.
pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expansion: f64, linear: bool) -> Mask {
let scale = m.mean_scale();
let path = to_kurbo(sp);
let hard = fill_coverage(std::slice::from_ref(&path), m, w, h, FillRule::NonZero);
let flat = flatten(sp, m);
if flat.pts.len() < 2 {
return hard;
}
let mut pts: Vec<(f64, f64, f64, f64)> = sp
.feather
.iter()
.filter_map(|f| {
let u = flat.arc_of(f)?;
let r = f.radius * scale;
Some((u, r.max(0.0), (-r).max(0.0), f.tension))
})
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
let prof = Profile { pts, len: flat.len, closed: sp.closed };
let rmax = prof.pts.iter().map(|p| p.1.max(p.2)).fold(0.0, f64::max);
let exp = expansion * scale;
let reach = rmax + exp.abs() + 2.0;
let (wu, hu) = (w as usize, h as usize);
let mut dist = vec![f32::INFINITY; wu * hu];
let mut arc = vec![0.0f32; wu * hu];
for k in 0..flat.pts.len() - 1 {
let (a, b) = (flat.pts[k], flat.pts[k + 1]);
let (sa, sb) = (flat.s[k], flat.s[k + 1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let x0 = ((a[0].min(b[0]) - reach).floor().max(0.0)) as usize;
let x1 = ((a[0].max(b[0]) + reach).ceil().min(w as f64 - 1.0)).max(-1.0) as isize;
let y0 = ((a[1].min(b[1]) - reach).floor().max(0.0)) as usize;
let y1 = ((a[1].max(b[1]) + reach).ceil().min(h as f64 - 1.0)).max(-1.0) as isize;
if x1 < 0 || y1 < 0 {
continue;
}
for y in y0..=y1 as usize {
for x in x0..=x1 as usize {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let t = if l2 > 0.0 { (((px - a[0]) * dx + (py - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + dx * t - px, a[1] + dy * t - py);
let d = (qx * qx + qy * qy).sqrt() as f32;
let i = y * wu + x;
if d < dist[i] {
dist[i] = d;
arc[i] = (sa + (sb - sa) * t) as f32;
}
}
}
}
let ramp = |x: f64| {
let x = x.clamp(0.0, 1.0);
if linear { x } else { x * x * (3.0 - 2.0 * x) }
};
let mut out = Mask::new(w, h, 0.0);
for (i, o) in out.data.iter_mut().enumerate() {
let inside = hard.data[i] >= 0.5;
let d = dist[i] as f64;
if !d.is_finite() || d > reach {
// Farther from the path than any feather or expansion reaches.
*o = if inside { 1.0 } else { 0.0 };
continue;
}
// Signed distance to the expanded edge (negative inside).
let sd = if inside { -d } else { d } - exp;
let (ro, ri) = prof.at(arc[i] as f64);
let a = if sd > 0.0 {
if ro > 0.5 { ramp(1.0 - sd / ro) } else { (0.5 - sd).clamp(0.0, 1.0) }
} else if ri > 0.5 {
ramp(-sd / ri)
} else {
(0.5 - sd).clamp(0.0, 1.0)
};
*o = a as f32;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn square() -> ShapePath {
// 100×100 square from (50,50) to (150,150), clockwise: top, right, bottom, left segments.
ShapePath::rect([100.0, 100.0], 100.0, 100.0)
}
fn at(m: &Mask, x: u32, y: u32) -> f32 {
m.data[(y * m.width + x) as usize]
}
#[test]
fn no_points_is_the_hard_mask() {
let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false);
assert!(at(&m, 100, 100) > 0.99 && at(&m, 40, 100) < 0.01);
}
#[test]
fn single_outer_point_feathers_all_around() {
let mut sp = square();
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 0.0 }];
let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true);
// Above the top edge: 1 at the edge falling linearly to 0 at 20 px.
assert!((at(&m, 100, 39) - 0.475).abs() < 0.06, "{}", at(&m, 100, 39));
assert!(at(&m, 100, 28) < 0.01);
assert!(at(&m, 100, 60) > 0.99, "inside stays opaque");
// One point applies to the whole closed path.
assert!((at(&m, 161, 100) - 0.475).abs() < 0.06, "{}", at(&m, 161, 100));
}
#[test]
fn outer_and_inner_points_vary_along_the_path() {
let mut sp = square();
// Outer 20 px in the middle of the top edge, inner 20 px in the middle of the bottom edge.
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 1.0 }, FeatherPoint { segment: 2, t: 0.5, radius: -20.0, tension: 1.0 }];
let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true);
// Top: soft outside, hard inside.
assert!(at(&m, 100, 45) > 0.6 && at(&m, 100, 45) < 0.8, "{}", at(&m, 100, 45));
assert!(at(&m, 100, 52) > 0.99);
// Bottom: hard outside, soft inside (0 at the edge, 1 at 20 px in).
assert!(at(&m, 100, 152) < 0.01);
assert!((at(&m, 100, 140) - 0.475).abs() < 0.06, "{}", at(&m, 100, 140));
// Half way round (middle of the right edge): both radii are half way, 10 px.
assert!((at(&m, 154, 100) - 0.55).abs() < 0.05, "{}", at(&m, 154, 100));
assert!((at(&m, 145, 100) - 0.45).abs() < 0.05, "{}", at(&m, 145, 100));
}
#[test]
fn scale_and_expansion() {
let mut sp = square();
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 10.0, tension: 0.0 }];
// Half resolution: the 10 px feather is 5 buffer pixels.
let m = variable_feather_coverage(&sp, &Mat3::scale(vec2(0.5, 0.5)), 100, 100, 0.0, true);
assert!(at(&m, 50, 22) > 0.4 && at(&m, 50, 22) < 0.6, "{}", at(&m, 50, 22));
let e = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 10.0, true);
assert!(at(&e, 100, 41) > 0.99, "expanded by 10 px: {}", at(&e, 100, 41));
assert!((at(&e, 100, 34) - 0.45).abs() < 0.05, "{}", at(&e, 100, 34));
}
}

View File

@ -191,7 +191,7 @@ fn from_cubics(cs: &[[P; 4]], closed: bool) -> ShapePath {
v[j] = c[3];
}
}
ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed }
ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed, feather: Vec::new() }
}
/// Exactly the same outline with `total` vertices (≥ the current count), the extra vertices
@ -272,7 +272,13 @@ fn rotate(p: &ShapePath, k: usize) -> ShapePath {
return p.clone();
}
let r = |v: &Vec<P>| (0..n).map(|i| v[(i + k) % n]).collect();
ShapePath { vertices: r(&p.vertices), in_tangents: r(&p.in_tangents), out_tangents: r(&p.out_tangents), closed: p.closed }
ShapePath {
vertices: r(&p.vertices),
in_tangents: r(&p.in_tangents),
out_tangents: r(&p.out_tangents),
closed: p.closed,
feather: p.feather.iter().map(|f| effectcraft_keyframe::FeatherPoint { segment: (f.segment + n - k % n) % n, ..*f }).collect(),
}
}
/// The same path traversed the other way (closed paths keep vertex 0 first).
@ -287,6 +293,8 @@ pub fn reversed(p: &ShapePath) -> ShapePath {
in_tangents: idx.iter().map(|i| p.out_tangents[*i]).collect(),
out_tangents: idx.iter().map(|i| p.in_tangents[*i]).collect(),
closed: p.closed,
// Feather points sit on segments that reversal renumbers: they are not carried.
feather: Vec::new(),
}
}
@ -447,6 +455,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap
in_tangents: lin(&a.in_tangents, &b.in_tangents),
out_tangents: lin(&a.out_tangents, &b.out_tangents),
closed: a.closed,
feather: a.feather.clone(),
};
}
// Procrustes: b ≈ s R (a − ca) + cb.
@ -468,7 +477,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap
let (th_u, s_u) = (theta * u, s.max(1e-9).powf(u));
let c_u = lerp(ca, cb, u);
let w = (o.bending_resistance / 100.0).clamp(0.0, 1.0);
let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed };
let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed, feather: a.feather.clone() };
for i in 0..n {
// Rigid path: a's vertex carried by the interpolated similarity, plus the residual
// (b − fully transformed a) blended in linearly.

View File

@ -6,10 +6,12 @@
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
pub mod boolean;
pub mod feather;
mod fit;
pub mod interp;
pub mod offset;
pub mod ops;
pub mod varstroke;
use effectcraft_geom::Mat3;
use effectcraft_keyframe::ShapePath;
@ -73,7 +75,13 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
match *el {
PathEl::MoveTo(p) => {
flush(&mut cur, &mut out);
cur = Some(ShapePath { vertices: vec![[p.x, p.y]], in_tangents: vec![[0.0; 2]], out_tangents: vec![[0.0; 2]], closed: false });
cur = Some(ShapePath {
vertices: vec![[p.x, p.y]],
in_tangents: vec![[0.0; 2]],
out_tangents: vec![[0.0; 2]],
closed: false,
feather: Vec::new(),
});
last = p;
}
PathEl::LineTo(p) => {
@ -251,11 +259,22 @@ pub struct StrokeStyle {
pub miter: f64,
/// Dash pattern (dash, gap, …) and offset.
pub dash: Option<(Vec<f64>, f64)>,
/// Stroke Taper (variable width at the ends).
pub taper: Option<varstroke::Taper>,
/// Stroke Wave (periodic width).
pub wave: Option<varstroke::Wave>,
}
impl StrokeStyle {
/// Whether the width varies along the path (Taper or Wave active).
pub fn is_variable(&self) -> bool {
self.taper.as_ref().is_some_and(|t| t.is_active()) || self.wave.as_ref().is_some_and(|w| w.is_active())
}
}
impl Default for StrokeStyle {
fn default() -> Self {
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None }
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, taper: None, wave: None }
}
}
@ -264,6 +283,9 @@ pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) ->
if style.width <= 0.0 {
return None;
}
if style.is_variable() {
return varstroke::outline(paths, style, res_scale);
}
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());

View File

@ -0,0 +1,399 @@
//! Variable-width strokes: the shape Stroke's **Taper** (start/end length, width and ease) and
//! **Wave** (amount, wavelength or cycles, phase), with dashes cut along the same arc length.
//!
//! The stroke is built as a union of positively-wound primitives: one trapezoid per flattened
//! segment (its two ends at the local widths), a disc at every bend (round joins) and the caps.
//! Filled with the non-zero rule the union is exact, whatever the width profile does.
use kurbo::{BezPath, PathEl, Point, Shape, Vec2};
use crate::{Cap, StrokeStyle};
/// Stroke Taper. Lengths are pixels, or fractions of each path's length when `percent`; widths and
/// eases are fractions (0–1). A width of 0 tapers to a point.
#[derive(Clone, Debug, PartialEq)]
pub struct Taper {
pub percent: bool,
pub start_len: f64,
pub end_len: f64,
pub start_width: f64,
pub end_width: f64,
pub start_ease: f64,
pub end_ease: f64,
}
impl Taper {
pub fn is_active(&self) -> bool {
(self.start_len > 0.0 && self.start_width < 1.0) || (self.end_len > 0.0 && self.end_width < 1.0)
}
}
/// Stroke Wave: the width oscillates between `1 - amount` and 1 of the stroke width.
#[derive(Clone, Debug, PartialEq)]
pub struct Wave {
/// 0–1.
pub amount: f64,
/// Units: `Some(cycles)` = cycles along each path, else `wavelength` in pixels.
pub cycles: Option<f64>,
pub wavelength: f64,
/// Degrees.
pub phase: f64,
}
impl Wave {
pub fn is_active(&self) -> bool {
self.amount > 0.0 && self.cycles.map_or(self.wavelength > 0.0, |c| c > 0.0)
}
}
/// Ease profile from the tip (`u = 0`) to full width (`u = 1`): linear at ease 0, a quarter
/// circle (bulging outwards) at ease 1.
fn ease(u: f64, e: f64) -> f64 {
let u = u.clamp(0.0, 1.0);
let round = (1.0 - (1.0 - u) * (1.0 - u)).max(0.0).sqrt();
u + (round - u) * e.clamp(0.0, 1.0)
}
/// Width factor (0–1) at arc length `s` of a path `len` long.
pub fn width_factor(taper: Option<&Taper>, wave: Option<&Wave>, s: f64, len: f64) -> f64 {
let mut k = 1.0;
if let Some(t) = taper.filter(|t| t.is_active()) {
let (mut ls, mut le) = if t.percent { (t.start_len * len, t.end_len * len) } else { (t.start_len, t.end_len) };
ls = ls.max(0.0);
le = le.max(0.0);
// Overlapping tapers share the path in proportion.
if ls + le > len && ls + le > 0.0 {
let f = len / (ls + le);
ls *= f;
le *= f;
}
let sw = t.start_width.clamp(0.0, 1.0);
let ew = t.end_width.clamp(0.0, 1.0);
if ls > 0.0 && s < ls {
k = f64::min(k, sw + (1.0 - sw) * ease(s / ls, t.start_ease));
}
if le > 0.0 && s > len - le {
k = f64::min(k, ew + (1.0 - ew) * ease((len - s) / le, t.end_ease));
}
}
if let Some(w) = wave.filter(|w| w.is_active()) {
let lambda = match w.cycles {
Some(c) => len / c,
None => w.wavelength,
};
if lambda > 0.0 {
let ph = std::f64::consts::TAU * s / lambda + w.phase.to_radians();
k *= 1.0 - w.amount.clamp(0.0, 1.0) * (0.5 - 0.5 * ph.cos());
}
}
k
}
/// A flattened subpath: points and their cumulative arc lengths.
struct Poly {
pts: Vec<Point>,
s: Vec<f64>,
closed: bool,
}
impl Poly {
fn len(&self) -> f64 {
self.s.last().copied().unwrap_or(0.0)
}
/// Point at arc length `t` (clamped), and the index of the segment it lies on.
fn at(&self, t: f64) -> (Point, usize) {
let i = self.s.partition_point(|&x| x <= t).clamp(1, self.pts.len() - 1);
let (a, b) = (self.s[i - 1], self.s[i]);
let u = if b > a { ((t - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 };
(self.pts[i - 1].lerp(self.pts[i], u), i)
}
}
/// Flatten each subpath, then subdivide so no segment is longer than `step`.
fn flatten(path: &BezPath, tol: f64, step: f64) -> Vec<Poly> {
let mut out: Vec<Poly> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
let mut closed = false;
let flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Poly>| {
let mut pts: Vec<Point> = Vec::with_capacity(cur.len());
for &p in cur.iter() {
if pts.last().is_none_or(|q: &Point| q.distance(p) > 1e-9) {
pts.push(p);
}
}
if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) > 1e-9 {
pts.push(pts[0]);
}
cur.clear();
if pts.len() < 2 {
return;
}
// Subdivide.
let mut fine = vec![pts[0]];
for w in pts.windows(2) {
let d = w[0].distance(w[1]);
let n = (d / step).ceil().max(1.0) as usize;
for k in 1..=n {
fine.push(w[0].lerp(w[1], k as f64 / n as f64));
}
}
let mut s = Vec::with_capacity(fine.len());
let mut acc = 0.0;
s.push(0.0);
for w in fine.windows(2) {
acc += w[0].distance(w[1]);
s.push(acc);
}
out.push(Poly { pts: fine, s, closed });
};
kurbo::flatten(path.elements().iter().copied(), tol, |el| match el {
PathEl::MoveTo(p) => {
flush(&mut cur, closed, &mut out);
closed = false;
cur.push(p);
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
closed = true;
flush(&mut cur, closed, &mut out);
closed = false;
}
_ => {}
});
flush(&mut cur, closed, &mut out);
out
}
/// Arc-length intervals that are "on" for a dash pattern on a path `len` long.
pub fn dash_intervals(pattern: &[f64], offset: f64, len: f64) -> Vec<(f64, f64)> {
let pat: Vec<f64> = pattern.iter().map(|v| v.max(0.0)).collect();
let pat = if pat.len() % 2 == 1 { [pat.clone(), pat].concat() } else { pat };
let period: f64 = pat.iter().sum();
if pat.len() < 2 || period <= 1e-9 || len <= 0.0 {
return vec![(0.0, len)];
}
// Pattern position at s = 0.
let mut pos = offset.rem_euclid(period);
let mut idx = 0;
while pos >= pat[idx] {
pos -= pat[idx];
idx = (idx + 1) % pat.len();
}
let mut s = 0.0;
let mut out = Vec::new();
let mut guard = 0usize;
while s < len && guard < 1_000_000 {
guard += 1;
let seg = pat[idx] - pos;
let e = (s + seg).min(len);
if idx % 2 == 0 && e >= s {
out.push((s, e));
}
s += seg;
pos = 0.0;
idx = (idx + 1) % pat.len();
}
out
}
/// Append `p` to `out`, wound positively.
fn push_pos(out: &mut BezPath, p: BezPath) {
let p = if p.area() < 0.0 { p.reverse_subpaths() } else { p };
out.extend(p.elements().iter().copied());
}
fn quad(out: &mut BezPath, pts: [Point; 4]) {
let mut p = BezPath::new();
p.move_to(pts[0]);
p.line_to(pts[1]);
p.line_to(pts[2]);
p.line_to(pts[3]);
p.close_path();
if p.area().abs() > 1e-12 {
push_pos(out, p);
}
}
fn disc(out: &mut BezPath, c: Point, r: f64) {
if r > 1e-6 {
push_pos(out, kurbo::Circle::new(c, r).to_path(0.05));
}
}
/// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale).
pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option<BezPath> {
if style.width <= 0.0 {
return None;
}
let rs = res_scale.max(0.01);
let mut out = BezPath::new();
let half = style.width / 2.0;
for path in paths {
// Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path.
let rough = flatten(path, 0.1 / rs, f64::INFINITY);
for poly0 in rough {
let len0 = poly0.len();
if len0 <= 0.0 {
continue;
}
let step = (0.5 / rs).max(len0 / 20_000.0);
let mut bp = BezPath::new();
bp.move_to(poly0.pts[0]);
for p in &poly0.pts[1..] {
bp.line_to(*p);
}
let poly = flatten(&bp, 0.1 / rs, step).pop()?;
let poly = Poly { closed: poly0.closed, ..poly };
let len = poly.len();
let wf = |s: f64| half * width_factor(style.taper.as_ref(), style.wave.as_ref(), s, len);
let intervals = match &style.dash {
Some((d, off)) if d.iter().any(|v| *v > 0.0) => dash_intervals(d, *off, len),
_ => vec![(0.0, len)],
};
let whole = intervals.len() == 1 && intervals[0].0 <= 0.0 && intervals[0].1 >= len;
for (a, b) in intervals {
// The piece's points: interpolated ends plus every vertex strictly inside.
let (pa, ia) = poly.at(a);
let (pb, ib) = poly.at(b);
let mut pts = vec![(pa, a)];
for i in ia..ib {
pts.push((poly.pts[i], poly.s[i]));
}
pts.push((pb, b));
pts.dedup_by(|x, y| x.0.distance(y.0) < 1e-9);
if pts.len() < 2 {
// A zero-length dash: only its caps show.
if matches!(style.cap, Cap::Round) {
disc(&mut out, pa, wf(a));
}
continue;
}
let closed = whole && poly.closed;
let dir = |i: usize| (pts[i + 1].0 - pts[i].0).normalize();
for i in 0..pts.len() - 1 {
let d = dir(i);
let n = Vec2::new(-d.y, d.x);
let (p0, s0) = pts[i];
let (p1, s1) = pts[i + 1];
let (w0, w1) = (wf(s0), wf(s1));
quad(&mut out, [p0 + n * w0, p1 + n * w1, p1 - n * w1, p0 - n * w0]);
// Joins: a disc where the direction turns.
if i > 0 && dir(i - 1).dot(d) < 0.9999 {
disc(&mut out, p0, w0);
}
}
if closed {
disc(&mut out, pts[0].0, wf(0.0));
continue;
}
// Caps.
let last = pts.len() - 1;
let ends = [(pts[0].0, wf(pts[0].1), -dir(0)), (pts[last].0, wf(pts[last].1), dir(last - 1))];
for (p, w, outward) in ends {
match style.cap {
Cap::Butt => {}
Cap::Round => disc(&mut out, p, w),
Cap::Square => {
let n = Vec2::new(-outward.y, outward.x);
let q = p + outward * w;
quad(&mut out, [p + n * w, q + n * w, q - n * w, p - n * w]);
}
}
}
}
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FillRule, fill_coverage};
use effectcraft_geom::Mat3;
fn line(len: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((10.0, 50.0));
p.line_to((10.0 + len, 50.0));
p
}
fn column(m: &effectcraft_raster::Mask, x: usize) -> f32 {
(0..m.height as usize).map(|y| m.data[y * m.width as usize + x]).sum()
}
#[test]
fn taper_width_profile() {
let t = Taper { percent: false, start_len: 50.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 };
assert!(width_factor(Some(&t), None, 0.0, 200.0).abs() < 1e-9);
assert!((width_factor(Some(&t), None, 25.0, 200.0) - 0.5).abs() < 1e-9);
assert!((width_factor(Some(&t), None, 100.0, 200.0) - 1.0).abs() < 1e-9);
// Ease bulges the profile outwards.
let e = Taper { start_ease: 1.0, ..t.clone() };
assert!(width_factor(Some(&e), None, 25.0, 200.0) > 0.8);
// Percent units and overlapping tapers.
let p = Taper { percent: true, start_len: 0.75, end_len: 0.75, start_width: 0.0, end_width: 0.0, ..t };
assert!((width_factor(Some(&p), None, 100.0, 200.0) - 1.0).abs() < 1e-9, "meet in the middle at full width");
assert!(width_factor(Some(&p), None, 50.0, 200.0) < 0.6);
}
#[test]
fn wave_width_profile() {
let w = Wave { amount: 1.0, cycles: None, wavelength: 40.0, phase: 0.0 };
assert!((width_factor(None, Some(&w), 0.0, 200.0) - 1.0).abs() < 1e-9);
assert!(width_factor(None, Some(&w), 20.0, 200.0).abs() < 1e-9);
let c = Wave { cycles: Some(5.0), ..w.clone() };
assert!(width_factor(None, Some(&c), 20.0, 200.0).abs() < 1e-9, "5 cycles over 200 px = 40 px");
let ph = Wave { phase: 180.0, ..w };
assert!(width_factor(None, Some(&ph), 0.0, 200.0).abs() < 1e-9);
}
#[test]
fn tapered_line_coverage_grows_along_the_path() {
let t = Taper { percent: false, start_len: 100.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 };
let st = StrokeStyle { width: 20.0, taper: Some(t), ..Default::default() };
let o = outline(&[line(150.0)], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero);
let (c1, c2, c3) = (column(&m, 30), column(&m, 70), column(&m, 140));
assert!((c1 - 4.0).abs() < 1.0, "20% along the taper → 4 px: {c1}");
assert!((c2 - 12.0).abs() < 1.0, "{c2}");
assert!((c3 - 20.0).abs() < 0.6, "{c3}");
let area: f32 = m.data.iter().sum();
assert!((area - (100.0 * 10.0 + 50.0 * 20.0)).abs() < 40.0, "{area}");
}
#[test]
fn dash_intervals_multi_segment() {
let iv = dash_intervals(&[10.0, 5.0, 2.0, 3.0], 0.0, 45.0);
assert_eq!(iv, vec![(0.0, 10.0), (15.0, 17.0), (20.0, 30.0), (35.0, 37.0), (40.0, 45.0)]);
let off = dash_intervals(&[10.0, 10.0], 5.0, 30.0);
assert_eq!(off, vec![(0.0, 5.0), (15.0, 25.0)]);
}
#[test]
fn waved_dashed_stroke_has_gaps() {
let st = StrokeStyle {
width: 10.0,
dash: Some((vec![20.0, 10.0], 0.0)),
wave: Some(Wave { amount: 0.5, cycles: None, wavelength: 30.0, phase: 0.0 }),
..Default::default()
};
let o = outline(&[line(150.0)], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero);
assert!(column(&m, 41) > 9.0, "dash at full width (wave crest): {}", column(&m, 41));
assert!(column(&m, 35) < 0.01, "gap");
assert!(column(&m, 55) > 4.0 && column(&m, 55) < 6.5, "wave trough ≈ half width: {}", column(&m, 55));
}
#[test]
fn closed_tapered_path_is_continuous() {
let r = crate::rect([60.0, 60.0], [50.0, 50.0], 0.0);
let st = StrokeStyle { width: 6.0, wave: Some(Wave { amount: 0.2, cycles: Some(4.0), wavelength: 0.0, phase: 0.0 }), ..Default::default() };
let o = outline(&[r], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 100, 100, FillRule::NonZero);
assert!(m.data.iter().all(|v| *v <= 1.0001));
let area: f32 = m.data.iter().sum();
assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}");
}
}

View File

@ -438,8 +438,35 @@ pub fn shape_stroke(ids: &mut Ids, color: [f64; 4], width: f64) -> PropGroup {
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
.with(stroke_taper(ids))
.with(stroke_wave(ids))
}
/// Stroke ▸ Taper (After Effects 17.1+): width ramps at the start and end of each path.
pub fn stroke_taper(ids: &mut Ids) -> PropGroup {
ids.group("taper", "Taper")
.with(ids.prop("units", "Length Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Percent"])))
.with(ids.prop("startLength", "Start Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("endLength", "End Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("startWidth", "Start Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("endWidth", "End Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("startEase", "Start Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("endEase", "End Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
}
/// Stroke ▸ Wave: the width oscillates along the path.
pub fn stroke_wave(ids: &mut Ids) -> PropGroup {
ids.group("wave", "Wave")
.with(ids.prop("amount", "Amount", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Cycles"])))
.with(ids.prop("wavelength", "Wavelength", Value::Scalar(20.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("cycles", "Cycles", Value::Scalar(10.0)).with_ui(slider(0.0, 1000.0, 0.0, 50.0, 1)))
.with(ids.prop("phase", "Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
}
/// Match ids of the optional Dash/Gap pairs After Effects adds with the Dashes "+" button.
pub const EXTRA_DASHES: [(&str, &str); 2] = [("dash2", "gap2"), ("dash3", "gap3")];
pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient) -> PropGroup {
indexed(ids.group("gfill", "Gradient Fill 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
@ -475,6 +502,8 @@ pub fn shape_gradient_stroke(ids: &mut Ids, radial: bool, start: [f64; 2], end:
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
.with(stroke_taper(ids))
.with(stroke_wave(ids))
}
pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup {

View File

@ -195,7 +195,12 @@ pub struct Comp {
pub markers: Vec<Marker>,
pub shutter_angle: f64,
pub shutter_phase: f64,
/// Samples Per Frame: the minimum motion-blur samples (and the count for 3D layers).
pub motion_blur_samples: u32,
/// Adaptive Sample Limit (Composition Settings ▸ Advanced): the most samples a 2D layer gets
/// when it moves far within the shutter.
#[serde(default = "adaptive_limit_default")]
pub motion_blur_adaptive_limit: u32,
#[serde(default)]
pub renderer: Renderer,
/// Comp switches (timeline toolbar).
@ -231,6 +236,10 @@ fn yes() -> bool {
true
}
fn adaptive_limit_default() -> u32 {
128
}
impl Comp {
pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp {
Comp {
@ -247,6 +256,7 @@ impl Comp {
shutter_angle: 180.0,
shutter_phase: -90.0,
motion_blur_samples: 16,
motion_blur_adaptive_limit: 128,
renderer: Renderer::Classic3D,
hide_shy: false,
enable_motion_blur: true,

View File

@ -529,6 +529,13 @@ impl PropGroup {
})
}
/// Parent group of the node with `uid`.
pub fn parent_of(&self, uid: Uid) -> Option<&PropGroup> {
if self.children.iter().any(|c| c.uid() == uid) {
return Some(self);
}
self.groups().find_map(|g| g.parent_of(uid))
}
/// Parent group of the node with `uid`.
pub fn parent_of_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> {
if self.children.iter().any(|c| c.uid() == uid) {
return Some(self);

View File

@ -1045,13 +1045,51 @@ impl<'a> Renderer<'a> {
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Motion-blur sub-samples for a layer (1 = no motion blur).
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize {
if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
/// Whether a layer is motion blurred.
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
}
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
/// layer gets about one sample per output pixel its corners travel during the shutter,
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
/// does not move within the shutter is drawn once.
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
if !self.mb_on(ctx, layer) {
return 1;
}
let travel = self.shutter_travel(ctx, layer, buf);
if travel < 1e-3 {
return 1;
}
if self.opts.draft {
return 4;
}
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
}
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
/// measured along the path at a few points.
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
const PROBES: usize = 8;
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
let mut travel = vec![0.0f64; corners.len()];
for i in 0..PROBES {
let f = phase + angle * i as f64 / (PROBES - 1) as f64;
let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf);
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
if let Some(p) = &prev {
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
travel[k] += (b.x - a.x).hypot(b.y - a.y);
}
}
prev = Some(pts);
}
travel.into_iter().fold(0.0, f64::max)
}
/// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's
@ -1061,7 +1099,7 @@ impl<'a> Renderer<'a> {
/// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank
/// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise.
fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region {
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 {
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 {
return Region::Full;
}
let m = self.buf_matrix(ctx, layer, buf);
@ -1096,7 +1134,7 @@ impl<'a> Renderer<'a> {
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
/// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed.
pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement {
let samples = self.mb_samples(ctx, layer);
let samples = self.mb_samples(ctx, layer, buf);
let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32);
if samples <= 1 {
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
@ -1457,6 +1495,14 @@ pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image {
}
/// Convenience: render one frame of a comp with no footage source.
/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels
/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit.
pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize {
let lo = per_frame.clamp(2, 64) as f64;
let hi = (limit.clamp(16, 256) as f64).max(lo);
travel.ceil().clamp(lo, hi) as usize
}
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}

View File

@ -54,9 +54,17 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
let path = effectcraft_path::to_kurbo(&sp);
let (w, h) = (buf.img.width, buf.img.height);
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero);
let exp = ctx.f(layer, g, "expansion", 0.0);
if exp.abs() > 0.01 {
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
// Variable-width feather (Mask Feather tool points): expansion and the per-point feather
// are applied together from the signed distance to the path.
let variable = !sp.feather.is_empty();
let mut cov = if variable {
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
};
if exp.abs() > 0.01 && !variable {
let ring = effectcraft_path::stroke_coverage(
std::slice::from_ref(&path),
&StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() },
@ -71,7 +79,6 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
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 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 });

View File

@ -12,7 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
@ -37,6 +37,8 @@ enum Paint {
end: [f64; 2],
rule: FillRule,
stroke: Option<StrokeStyle>,
/// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees).
highlight: (f64, f64),
},
}
@ -102,7 +104,35 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
if dash <= 0.0 {
return None;
}
let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }];
// Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button).
for (dm, gm) in effectcraft_project::build::EXTRA_DASHES {
if d.get(dm).is_none() {
break;
}
let dv = ctx.f(layer, d, dm, 0.0);
pat.push(dv);
pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv });
}
Some((pat, ctx.f(layer, d, "offset", 0.0)))
});
let taper = g.sub("taper").map(|t| varstroke::Taper {
percent: ctx.e(layer, t, "units") == 1,
start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0,
end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0,
start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0,
end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0,
});
let wave = g.sub("wave").map(|w| varstroke::Wave {
amount: ctx.f(layer, w, "amount", 0.0) / 100.0,
cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)),
wavelength: ctx.f(layer, w, "wavelength", 20.0),
phase: ctx.f(layer, w, "phase", 0.0),
});
StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
@ -110,6 +140,8 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
taper: taper.filter(|t| t.is_active()),
wave: wave.filter(|w| w.is_active()),
}
}
@ -220,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
stroke,
highlight: (ctx.f(layer, g, "highlightLength", 0.0), ctx.f(layer, g, "highlightAngle", 0.0)),
},
slots: live.clone(),
xf: Mat3::IDENTITY,
@ -359,6 +392,54 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
(draws, live)
}
/// Gradient position of a point: linear along start→end, radial by distance from the start over
/// the radius |end − start|. A radial Highlight shifts the focal point towards
/// `highlight_length` % of the radius at `highlight_angle` from the start→end axis (a focal
/// two-point gradient: t is measured from the focal point to the circle along the ray).
#[derive(Clone, Copy, Debug)]
pub(crate) struct GradientRamp {
radial: bool,
s: [f64; 2],
d: [f64; 2],
len2: f64,
focal: Option<[f64; 2]>,
}
impl GradientRamp {
pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self {
let d = [end[0] - start[0], end[1] - start[1]];
let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9);
let (hl, ha) = highlight;
let focal = (radial && hl.abs() > 1e-6).then(|| {
let r = len2.sqrt();
let a = d[1].atan2(d[0]) + ha.to_radians();
let dist = r * (hl / 100.0).clamp(-0.99, 0.99);
[start[0] + dist * a.cos(), start[1] + dist * a.sin()]
});
GradientRamp { radial, s: start, d, len2, focal }
}
pub(crate) fn t(&self, x: f64, y: f64) -> f64 {
let (vx, vy) = (x - self.s[0], y - self.s[1]);
if !self.radial {
return (vx * self.d[0] + vy * self.d[1]) / self.len2;
}
let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() };
// Ray from the focal point through p meets the circle (centre s, radius r) at λ.
let (px, py) = (x - f[0], y - f[1]);
let dist = (px * px + py * py).sqrt();
if dist < 1e-12 {
return 0.0;
}
let (ux, uy) = (px / dist, py / dist);
let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]);
let b = ux * cx + uy * cy;
let c = cx * cx + cy * cy - self.len2;
let lambda = -b + (b * b - c).max(0.0).sqrt();
if lambda <= 1e-12 { 1.0 } else { dist / lambda }
}
}
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PxRect {
@ -486,16 +567,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
match &d.paint {
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
Paint::Gradient { g, radial, start, end, .. } => {
Paint::Gradient { g, radial, start, end, highlight, .. } => {
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let ramp = GradientRamp::new(*radial, *start, *end, *highlight);
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
g.sample(t)
g.sample(ramp.t(p.x, p.y))
});
}
}

View File

@ -340,3 +340,87 @@ fn shape_perf_repeater_60_copies_1080p() {
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
}
fn line_path(ids: &mut Ids, from: [f64; 2], to: [f64; 2]) -> PropGroup {
let sp = effectcraft_keyframe::ShapePath {
vertices: vec![from, to],
in_tangents: vec![[0.0; 2]; 2],
out_tangents: vec![[0.0; 2]; 2],
closed: false,
feather: Vec::new(),
};
build::shape_path(ids, sp)
}
fn column_alpha(img: &Image, x: i64) -> f32 {
(0..img.height as i64).map(|y| alpha(img, x, y)).sum()
}
#[test]
fn stroke_taper_narrows_the_ends() {
// A horizontal line from x = 20 to 180 (layer −80…80), 20 px wide, tapered over 40 px at both ends.
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 20.0);
set(&mut st, "taper/startLength", Value::Scalar(40.0));
set(&mut st, "taper/endLength", Value::Scalar(40.0));
set(&mut st, "taper/startWidth", Value::Scalar(0.0));
set(&mut st, "taper/endWidth", Value::Scalar(50.0));
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
let near_start = column_alpha(&img, 30); // 10 px along → 25 % of 20 px
let mid = column_alpha(&img, 100);
let near_end = column_alpha(&img, 169); // 10 px before the end → (50 % + 50 % × 25 %) of 20 px
assert!((near_start - 5.0).abs() < 1.0, "{near_start}");
assert!((mid - 20.0).abs() < 0.6, "{mid}");
assert!((near_end - 12.5).abs() < 1.0, "{near_end}");
}
#[test]
fn stroke_wave_and_multi_dashes() {
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 10.0);
set(&mut st, "wave/amount", Value::Scalar(100.0));
set(&mut st, "wave/wavelength", Value::Scalar(40.0));
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
assert!(column_alpha(&img, 40) < 0.5, "trough (20 px along): {}", column_alpha(&img, 40));
assert!(column_alpha(&img, 60) > 9.0, "crest (40 px along): {}", column_alpha(&img, 60));
// Dash 20, Gap 10, Dash 2 = 5, Gap 2 = 15: on 0–20, 30–35, 50–70…
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 10.0);
set(&mut st, "dashes/dash", Value::Scalar(20.0));
set(&mut st, "dashes/gap", Value::Scalar(10.0));
let d = st.sub_mut("dashes").unwrap();
d.children.insert(2, ids.prop("gap2", "Gap 2", Value::Scalar(15.0)).into());
d.children.insert(2, ids.prop("dash2", "Dash 2", Value::Scalar(5.0)).into());
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
let on = |s: i64| column_alpha(&img, 20 + s) > 9.0;
assert!(on(10) && !on(25) && on(32) && !on(40) && on(60), "{:?}", (0..80).map(|s| on(s) as u8).collect::<Vec<_>>());
}
#[test]
fn radial_gradient_highlight_moves_the_focal_point() {
let build_with = |hl: f64| {
shape_comp_sized(200, 200, move |ids| {
let mut gf = build::shape_gradient_fill(ids, true, [0.0, 0.0], [80.0, 0.0], Gradient::default());
set(&mut gf, "highlightLength", Value::Scalar(hl));
set(&mut gf, "highlightAngle", Value::Scalar(90.0));
vec![build::shape_rect(ids, [200.0, 200.0], [0.0, 0.0], 0.0), gf]
})
};
let (p0, c0) = build_with(0.0);
let (p1, c1) = build_with(50.0);
let (a, b) = (frame(&p0, c0), frame(&p1, c1));
// Without a highlight the centre is white; a 50 % highlight at 90° (down the +y axis, 90° from
// the start→end axis) puts the white focus 40 px below the centre and darkens the centre.
assert!(a.get(100, 100)[0] > 0.98);
assert!(b.get(100, 140)[0] > 0.97, "focal point: {:?}", b.get(100, 140));
assert!(b.get(100, 100)[0] < 0.8, "{:?}", b.get(100, 100));
// The outer circle is unchanged: points just outside it are black in both.
assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03);
}

View File

@ -53,8 +53,33 @@ pub struct CharXf {
pub char_range: u32,
pub char_align: u32,
pub blur: [f64; 2],
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
pub axes: [([u8; 4], f64); 4],
pub n_axes: u8,
}
impl CharXf {
/// The variable-font axis offsets that move the outline.
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
self.axes[..self.n_axes as usize]
.iter()
.filter(|(_, d)| d.abs() > 1e-6)
.map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32))
.collect()
}
fn add_axis(&mut self, tag: [u8; 4], d: f64) {
if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) {
a.1 += d;
} else if (self.n_axes as usize) < self.axes.len() {
self.axes[self.n_axes as usize] = (tag, d);
self.n_axes += 1;
}
}
}
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
pub const AXIS_PREFIX: &str = "axis_";
impl Default for CharXf {
fn default() -> Self {
CharXf {
@ -86,6 +111,8 @@ impl Default for CharXf {
char_range: 0,
char_align: 1,
blur: [0.0; 2],
axes: [([0; 4], 0.0); 4],
n_axes: 0,
}
}
}
@ -245,6 +272,17 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
let axes: Vec<([u8; 4], f64)> = props
.props()
.filter_map(|p| {
let t = p.match_id.strip_prefix(AXIS_PREFIX)?;
let mut tag = [b' '; 4];
for (i, c) in t.bytes().take(4).enumerate() {
tag[i] = c;
}
Some((tag, ctx.value(layer, p).as_f64()))
})
.collect();
for (gi, k3) in sel.iter().enumerate() {
let k = k3[0];
if k3.iter().all(|x| *x == 0.0) {
@ -342,6 +380,9 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
c.blur[0] += (b[0] * k3[0]).abs();
c.blur[1] += (b[1] * k3[1]).abs();
}
for (tag, v) in &axes {
c.add_axis(*tag, v * k);
}
}
}
out
@ -404,6 +445,11 @@ pub struct PlacedGlyph {
pub stroke_width: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
/// Index of the character in the text.
pub char_index: usize,
/// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a
/// point of the static layout to where the animation puts this character.
pub rest: [f64; 2],
}
impl PlacedGlyph {
@ -463,6 +509,30 @@ fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeas
Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0)))
}
/// For every caret position `0..=chars` of the layer's text, the map from the static layout
/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put
/// the characters: the caret follows the character before it (or the first one).
pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec<Mat3> {
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
let mut by_char: Vec<Option<Mat3>> = vec![None; chars + 1];
for g in &geom.glyphs {
if let Some(slot) = by_char.get_mut(g.char_index) {
*slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1])));
}
}
let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY);
let mut out = Vec::with_capacity(chars + 1);
let mut last = first;
for ci in 0..=chars {
// Caret ci sits after character ci − 1.
if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) {
last = m;
}
out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last });
}
out
}
/// Lay out and animate a text layer.
pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let doc = source_text(ctx, layer)?;
@ -612,7 +682,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
};
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
}
None => (g.path.clone(), g.advance),
// Variable Font Axes: the outline redrawn at the animated design-space position.
None => match x.axis_deltas() {
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance),
_ => (g.path.clone(), g.advance),
},
};
if outline.elements().is_empty() {
continue;
@ -642,7 +716,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill });
let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)];
glyphs.push(PlacedGlyph {
local,
m,
xf: x,
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
apply_fill: st.apply_fill,
char_index: g.char_index,
rest,
});
}
Some(TextGeom { doc, glyphs, fill_stroke, blend })
}

View File

@ -228,7 +228,7 @@ fn anchor_grouping_line_rotates_around_line_centre() {
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed };
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
p.next_id = next;
let masks = l.props.group_mut("masks").unwrap();
@ -416,3 +416,39 @@ fn keyframed_selector_animates() {
assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]);
}
#[test]
fn caret_follows_animators_and_path_text() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["position"], &["range"]);
set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
assert_eq!(maps.len(), 5);
for (ci, m) in maps.iter().enumerate() {
let (top, _) = lay.caret(ci);
let q = m.apply(vec2(top.x, top.y));
assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}");
}
// On a vertical path (x = 100, running down) the caret turns with the characters and sits
// on the path.
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
let (t, b) = lay.caret(2);
let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y)));
assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}");
assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}");
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}

View File

@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() {
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
};
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);

View File

@ -0,0 +1,336 @@
//! Classic 3D depth of field: bokeh-shaped gather blur.
//!
//! The camera's Iris Shape (Fast Rectangle, or a regular polygon from Triangle to Decagon),
//! Iris Rotation, Roundness and Aspect Ratio define the blur kernel; Iris Diffraction Fringe
//! brightens its rim. Highlight Gain / Threshold / Saturation boost the bright parts of the layer
//! before blurring so they bloom into visible bokeh shapes. A layer whose depth varies across it
//! (tilted towards or away from the camera) is blurred progressively: the buffer is blurred at a
//! few kernel sizes and every pixel blends the two that bracket its own circle of confusion.
use effectcraft_raster::Image;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
/// Camera iris (Camera Options ▸ Iris …).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Iris {
/// 0 = Fast Rectangle, else the number of sides (3–10).
pub sides: u32,
/// Degrees.
pub rotation: f64,
/// −1…1: 1 = circular, negative pinches the sides inwards.
pub roundness: f64,
/// Width / height of the aperture.
pub aspect: f64,
/// Rim brightness: 0 = none, 1 = the rim is twice as bright (Iris Diffraction Fringe / 100).
pub fringe: f64,
}
impl Default for Iris {
fn default() -> Self {
Iris { sides: 0, rotation: 0.0, roundness: 0.0, aspect: 1.0, fringe: 0.0 }
}
}
/// Highlight boost before the blur (Camera Options ▸ Highlight …), all 0–1.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Highlight {
pub gain: f64,
pub threshold: f64,
pub saturation: f64,
}
impl Iris {
/// Iris Shape popup index → sides (0 Fast Rectangle, 1 Triangle … 8 Decagon).
pub fn sides_of_shape(shape: u32) -> u32 {
if shape == 0 { 0 } else { (shape + 2).min(10) }
}
/// Normalised boundary distance (1 = circumscribed circle) of the iris at angle `a`.
fn boundary(&self, a: f64) -> f64 {
let n = self.sides.max(3) as f64;
let seg = std::f64::consts::TAU / n;
let rel = (a - self.rotation.to_radians()).rem_euclid(seg) - seg * 0.5;
let poly = (std::f64::consts::PI / n).cos() / rel.cos();
let r = self.roundness.clamp(-1.0, 1.0);
// Positive: towards the circumscribed circle; negative: the sides bow inwards (the
// vertices stay put, the edge midpoints move in).
if r >= 0.0 { poly + (1.0 - poly) * r } else { (poly + (1.0 - poly) * r * 2.0).max(0.05) }
}
/// Whether the kernel offset (dx, dy) lies inside an iris of radius `r`.
fn inside(&self, r: f64, dx: f64, dy: f64) -> bool {
let a = self.aspect.clamp(0.01, 100.0).sqrt();
let (u, v) = (dx / a, dy * a);
let d = u.hypot(v);
d < 1e-9 || d <= self.boundary(v.atan2(u)) * r
}
/// The kernel as horizontal spans: for every row offset `dy`, the (fractional) x extent
/// `[x0, x1]` of the iris of radius `r` at that height. The iris is convex for roundness ≥ 0;
/// a pinched iris keeps its outermost extent per row.
pub fn spans(&self, r: f64) -> Vec<(i32, f64, f64)> {
let a = self.aspect.clamp(0.01, 100.0).sqrt();
let (ext_x, ext_y) = (r * a + 1.0, (r / a).ceil() as i32 + 1);
let mut out = Vec::new();
for dy in -ext_y..=ext_y {
let y = dy as f64;
// Coarse scan, then bisection of both ends.
let step = 0.5;
let mut first = None;
let mut last = None;
let mut x = -ext_x;
while x <= ext_x {
if self.inside(r, x, y) {
first.get_or_insert(x);
last = Some(x);
}
x += step;
}
let (Some(f), Some(l)) = (first, last) else { continue };
let refine = |inside_x: f64, outside_x: f64| {
let (mut i, mut o) = (inside_x, outside_x);
for _ in 0..12 {
let m = (i + o) * 0.5;
if self.inside(r, m, y) { i = m } else { o = m }
}
(i + o) * 0.5
};
out.push((dy, refine(f, f - step), refine(l, l + step)));
}
out
}
}
/// Prefix sums of each row (per channel): `p[x]` = sum of pixels `0..x`.
fn row_prefix(img: &Image) -> Vec<Vec<[f32; 4]>> {
let w = img.width as usize;
img.data
.par_chunks(w.max(1))
.map(|row| {
let mut acc = [0.0f32; 4];
let mut v = Vec::with_capacity(w + 1);
v.push(acc);
for p in row {
for c in 0..4 {
acc[c] += p[c];
}
v.push(acc);
}
v
})
.collect()
}
/// Continuous prefix: sum over [0, t) with linear interpolation inside a pixel.
#[inline]
fn prefix_at(p: &[[f32; 4]], t: f64) -> [f32; 4] {
let n = p.len() - 1;
if t <= 0.0 {
return [0.0; 4];
}
if t >= n as f64 {
return p[n];
}
let i = t.floor() as usize;
let f = (t - i as f64) as f32;
let (a, b) = (p[i], p[i + 1]);
[a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f]
}
/// Gather with span kernels: `(weight, spans)` pairs summed, then normalised by `norm`.
fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) -> Image {
let pre = row_prefix(img);
let (w, h) = (img.width as usize, img.height as i64);
let mut out = Image::new(img.width, img.height);
let inv = 1.0 / norm;
out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
for (wt, spans) in kernels {
for &(dy, x0, x1) in spans {
let sy = y as i64 + dy as i64;
if sy < 0 || sy >= h {
continue;
}
let pr = &pre[sy as usize];
let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5));
for c in 0..4 {
acc[c] += (hi[c] - lo[c]) * wt;
}
}
}
*o = acc.map(|v| v * inv);
}
});
out
}
/// Boost the highlights of a premultiplied image in place.
pub fn boost_highlights(img: &mut Image, h: &Highlight) {
if h.gain <= 0.0 {
return;
}
let thr = h.threshold.clamp(0.0, 1.0) as f32;
img.data.par_iter_mut().for_each(|p| {
if p[3] <= 0.0 {
return;
}
let a = p[3];
let (r, g, b) = (p[0] / a, p[1] / a, p[2] / a);
let l = effectcraft_color::luminance(r, g, b);
if l < thr || (thr >= 1.0 && l < 1.0) {
return;
}
let over = if thr < 1.0 { ((l - thr) / (1.0 - thr)).clamp(0.0, 1.0) } else { 1.0 };
let k = 1.0 + h.gain as f32 * 4.0 * over.max(0.25);
let sat = 1.0 + h.saturation as f32;
let c = [r, g, b].map(|c| (l + (c - l) * sat).max(0.0) * k);
*p = [c[0] * a, c[1] * a, c[2] * a, a];
});
}
/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels
/// beyond the image are transparent.
pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
if r < 0.3 {
return img.clone();
}
if iris.sides == 0 {
// Fast Rectangle: a separable box of the iris' width and height.
let a = iris.aspect.clamp(0.01, 100.0).sqrt();
let rx = (r * a).round().max(0.0) as usize;
let ry = (r / a).round().max(0.0) as usize;
return effectcraft_raster::box_blur(img, rx, ry, 1, false);
}
// The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The
// diffraction fringe adds the rim: the full iris minus a shrunken one.
let outer = iris.spans(r);
let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::<f32>();
let mut kernels = vec![(1.0f32, outer)];
if iris.fringe > 0.0 {
let rim = (r * 0.15).max(1.0);
let inner = if r - rim > 0.5 { iris.spans(r - rim) } else { Vec::new() };
let f = iris.fringe as f32;
kernels[0].0 = 1.0 + f;
kernels.push((-f, inner));
}
let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum();
if norm <= 0.0 {
return img.clone();
}
span_gather(img, &kernels, norm)
}
/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to
/// eight radii spanning the range and each pixel interpolates between the two levels around
/// its own radius.
pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image {
let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r)));
if !lo.is_finite() || hi < 0.3 {
return img.clone();
}
if hi - lo < 0.75 {
return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64);
}
let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8);
let levels: Vec<f32> = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect();
let blurred: Vec<Image> = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect();
let mut out = Image::new(img.width, img.height);
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
let r = radius[i];
let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
let k = (f.floor() as usize).min(n - 2);
let t = f - k as f32;
let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]);
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
});
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A single bright point in the middle of a transparent image.
fn dot(size: u32) -> Image {
let mut img = Image::new(size, size);
let c = (size / 2) as usize;
img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0];
img
}
fn lit(img: &Image, x: i32, y: i32) -> bool {
let c = img.width as i32 / 2;
img.data[((c + y) * img.width as i32 + c + x) as usize][3] > 1e-4
}
#[test]
fn kernel_shapes() {
// A hexagon with a vertex on +x (rotation 0): the point at 0.97 r on +x is inside, the
// point at 0.97 r at 30° (an edge midpoint at cos 30° = 0.866 r) is outside.
let hex = Iris { sides: 6, ..Default::default() };
let b = bokeh_blur(&dot(81), &hex, 20.0);
assert!(lit(&b, 19, 0));
let (x, y) = ((19.4f64 * 30f64.to_radians().cos()).round() as i32, (19.4f64 * 30f64.to_radians().sin()).round() as i32);
assert!(!lit(&b, x, y), "edge midpoint beyond the apothem");
// Rotating by 30° swaps them.
let rot = Iris { rotation: 30.0, ..hex };
let b = bokeh_blur(&dot(81), &rot, 20.0);
assert!(!lit(&b, 19, 0) && lit(&b, x, y));
// Full roundness: a circle.
let round = Iris { roundness: 1.0, ..hex };
let b = bokeh_blur(&dot(81), &round, 20.0);
assert!(lit(&b, 19, 0) && lit(&b, x, y));
// Triangle: far less area than a decagon of the same radius.
let area = |sides| {
let b = bokeh_blur(&dot(81), &Iris { sides, ..Default::default() }, 20.0);
b.data.iter().filter(|p| p[3] > 1e-4).count()
};
assert!(area(3) * 2 < area(10), "{} vs {}", area(3), area(10));
// Aspect ratio 4 makes it twice as wide and half as tall.
let wide = Iris { sides: 8, aspect: 4.0, ..Default::default() };
let b = bokeh_blur(&dot(121), &wide, 15.0);
assert!(lit(&b, 28, 0) && !lit(&b, 0, 10));
// Energy is preserved.
let sum: f32 = b.data.iter().map(|p| p[3]).sum();
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
}
#[test]
fn diffraction_fringe_brightens_the_rim() {
let plain = bokeh_blur(&dot(81), &Iris { sides: 8, ..Default::default() }, 20.0);
let fringe = bokeh_blur(&dot(81), &Iris { sides: 8, fringe: 2.0, ..Default::default() }, 20.0);
let at = |img: &Image, x: i32| img.data[(40 * 81 + 40 + x) as usize][3];
assert!((at(&plain, 0) - at(&plain, 17)).abs() < 1e-6, "flat disc");
assert!(at(&fringe, 18) > at(&fringe, 0) * 1.5, "{} vs {}", at(&fringe, 18), at(&fringe, 0));
}
#[test]
fn highlights_boost_only_above_threshold() {
let mut img = Image::new(2, 1);
img.data = vec![[0.9, 0.9, 0.9, 1.0], [0.3, 0.3, 0.3, 1.0]];
boost_highlights(&mut img, &Highlight { gain: 0.5, threshold: 0.8, saturation: 0.0 });
assert!(img.data[0][0] > 1.2 && img.data[1][0] == 0.3);
let mut c = Image::new(1, 1);
c.data = vec![[1.0, 0.8, 0.8, 1.0]];
boost_highlights(&mut c, &Highlight { gain: 0.1, threshold: 0.5, saturation: 1.0 });
assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]);
}
#[test]
fn progressive_blur_follows_the_radius_map() {
// A vertical line, blurred 0 px at the top rising to 8 px at the bottom.
let (w, h) = (41u32, 41u32);
let mut img = Image::new(w, h);
for y in 0..h as usize {
img.data[y * w as usize + 20] = [1.0; 4];
}
let radius: Vec<f32> = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / 40.0 * 8.0).collect();
let out = progressive_blur(&img, &Iris { sides: 6, roundness: 1.0, ..Default::default() }, &radius);
let spread = |y: usize| (0..w as usize).filter(|&x| out.data[y * w as usize + x][3] > 0.03).count();
assert!(spread(1) <= 3, "top stays sharp: {}", spread(1));
assert!(spread(39) >= 13, "bottom is wide: {}", spread(39));
assert!(spread(20) > spread(1) && spread(20) < spread(39));
}
}

View File

@ -26,6 +26,12 @@ pub struct Dof {
pub aperture: f64,
/// Blur Level (1.0 = 100%).
pub blur_level: f64,
/// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe.
#[serde(default)]
pub iris: super::bokeh::Iris,
/// Highlight Gain, Threshold and Saturation.
#[serde(default)]
pub highlight: super::bokeh::Highlight,
}
impl Dof {
@ -294,6 +300,18 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
focus: ctx.f(cam, g, "focusDistance", zoom),
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
iris: super::bokeh::Iris {
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
rotation: ctx.f(cam, g, "irisRotation", 0.0),
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
},
highlight: super::bokeh::Highlight {
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
},
});
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
}

View File

@ -206,26 +206,10 @@ fn prepare_with<'a>(
let cam = camera_for(r, ctx);
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
let world = outer * ctx.world_matrix(layer) * local;
// Depth of field: blur the layer by its circle of confusion at its centre.
// Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a
// layer facing the camera, progressive for a tilted one).
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
let b = buf_bounds(&buf);
let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0));
let z = cam.depth(centre);
let coc = dof.coc(z);
// Comp pixels on screen per layer pixel at that depth.
let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
let radius = coc * 0.5 / k * buf.scale;
if radius > 0.3 {
let sigma = radius / 1.5;
let pad = (sigma * 3.0).ceil() as u32 + 1;
let padded = buf.img.padded(pad);
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
buf = Arc::new(Buf {
img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false),
offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64],
scale: buf.scale,
});
}
buf = dof_blur(&cam, &dof, world, buf);
}
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
@ -269,6 +253,47 @@ fn prepare_with<'a>(
})
}
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
let s = buf.scale.max(1e-9);
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0)));
// Camera depth is affine across a plane.
let z0 = at(0.5, 0.5);
let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0);
let mut out = vec![0.0f32; w * h];
out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let z = z0 + zx * x as f64 + zy * y as f64;
if z <= super::camera::NEAR {
continue;
}
// coc · ½ / (zoom / z · sx) · scale
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
}
});
out
}
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
let radii = dof_radii(cam, dof, world, &buf);
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
if rmax <= 0.3 {
return buf;
}
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
let pad = (rmax * a).ceil() as u32 + 2;
let mut padded = buf.img.padded(pad);
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
let pr = dof_radii(cam, dof, world, &pbuf);
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale })
}
/// Screen-space track matte factor for a layer, or None.
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
let tm = layer.track_matte?;

View File

@ -6,6 +6,7 @@
//! groups, as in After Effects).
pub mod adv;
pub mod bokeh;
pub mod camera;
pub(crate) mod compose;
pub mod light;

View File

@ -423,6 +423,63 @@ fn depth_of_field_blurs_out_of_focus_layers() {
assert!(b.get(66, 50)[3] > 0.01);
}
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
}
#[test]
fn tilted_layer_blurs_progressively_with_depth() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]);
set(&mut s, "transform/rotationY", Value::Scalar(60.0));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
// The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of
// and behind it: soft.
let xs: Vec<i64> = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect();
let (x0, x1) = (xs[0], *xs.last().unwrap());
let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64;
let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9)));
assert!(centre <= 2, "centre {centre}");
assert!(left >= 4 && right >= 4, "ends {left} {right}");
}
#[test]
fn iris_shape_and_highlights_shape_the_bokeh() {
// A small bright square far out of focus: its blur takes the iris' shape.
let spot = |shape: u32, gain: f64| {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape));
set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain));
set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
render(&p, cid)
};
let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count();
let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0));
assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec));
// Fast Rectangle: a box.
let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x)));
let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64;
assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent);
// Highlight Gain brightens the blurred highlight.
let lit = spot(8, 100.0);
let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::<f32>();
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
}
// ---------------------------------------------------------------- views
#[test]

View File

@ -162,6 +162,8 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("stroke", "join", "ADBE Vector Stroke Line Join"),
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
("stroke", "taper", "ADBE Vector Stroke Taper"),
("stroke", "wave", "ADBE Vector Stroke Wave"),
("trim", "start", "ADBE Vector Trim Start"),
("trim", "end", "ADBE Vector Trim End"),
("trim", "offset", "ADBE Vector Trim Offset"),

View File

@ -130,6 +130,7 @@ fn item(s: &Session, a: &J) -> R {
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),

View File

@ -100,6 +100,8 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
},
miter: st.miter,
dash: st.dash.clone(),
taper: None,
wave: None,
};
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);

View File

@ -10,6 +10,7 @@ pub mod layout;
pub mod path_text;
pub mod selectors;
pub mod sfnt;
pub mod variable;
pub use kurbo;
@ -24,7 +25,7 @@ use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{GlyphId, MetadataProvider};
struct Pen(BezPath);
pub(crate) struct Pen(pub(crate) BezPath);
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
@ -70,10 +71,15 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
/// horizontal / vertical scale and faux italic.
pub fn glyph_outline(g: &Glyph) -> BezPath {
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
glyph_affine(g) * (*units).clone()
}
/// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic).
pub fn glyph_affine(g: &Glyph) -> Affine {
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
let slant = if g.synth_italic { 0.21 } else { 0.0 };
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0])
}
/// One character of laid-out text.
@ -97,6 +103,10 @@ pub struct CharGlyph {
pub size: f64,
/// Index into [`TextLayout::styles`].
pub run: usize,
/// Face and glyph id, and font units → `path` (for variable-font redraws).
pub face: FaceId,
pub gid: u32,
pub outline_xf: Affine,
}
#[derive(Clone, Debug, Default)]
@ -170,8 +180,9 @@ pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> ParagraphStyle {
justify_last,
leading: 0.0,
width,
// Left-to-right paragraphs keep the Unicode default (first strong character).
rtl: (p.direction == Direction::Rtl).then_some(true),
// The chosen direction is the paragraph's base direction (a left-to-right paragraph
// starting with Arabic or Hebrew still runs left to right, as in After Effects).
rtl: Some(p.direction == Direction::Rtl),
indent_start: p.indent_left as f32,
indent_end: p.indent_right as f32,
indent_first: p.indent_first as f32,
@ -182,6 +193,15 @@ pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> ParagraphStyle {
}
}
/// Characters set upright in vertical type: CJK ideographs, kana, hangul, full-width forms and
/// CJK punctuation. Everything else (Roman) turns on its side unless asked to stay upright.
pub fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0x30FF | 0x3100..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF
| 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF
| 0x20000..=0x3FFFF)
}
/// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns
/// running right to left), characters kept upright.
const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
@ -243,22 +263,56 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
for (li, line) in lay.lines.iter().enumerate() {
let o = to_layer * Point::new(line.x as f64, line.baseline as f64);
out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]);
for gi in line.glyphs.clone() {
// Vertical type: Tate-Chu-Yoko groups (consecutive glyphs of tcy runs) take one em of
// the column; everything after them moves up by the rest of their width.
let tcy = |gi: usize| doc.vertical && styles.get(lay.glyphs[gi].run).is_some_and(|s| s.tate_chu_yoko);
let mut shift = 0.0f64;
let mut group: Option<(f64, f64, f64)> = None; // (start x, width, em) in layout space
let line_glyphs: Vec<usize> = line.glyphs.clone().collect();
for (k, gi) in line_glyphs.iter().copied().enumerate() {
let g = &lay.glyphs[gi];
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
let src = text[g.cluster..].chars().next().unwrap_or(' ');
let st = styles.get(g.run);
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
let mut path = glyph_outline(g);
let mut outline_xf = glyph_affine(g);
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
let advance = (next_x - g.x) as f64;
if doc.vertical {
// Keep the character upright around its centre.
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
let rc = ROT90 * c;
path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path;
let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64);
if doc.vertical && tcy(gi) {
// Start of a group: measure it.
if group.is_none() {
let mut w = 0.0;
for &gj in line_glyphs[k..].iter().take_while(|&&gj| tcy(gj)) {
let nx = lay.glyphs.get(gj + 1).filter(|_| line.glyphs.contains(&(gj + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
w += (nx - lay.glyphs[gj].x) as f64;
}
group = Some((g.x as f64, w, g.size as f64));
}
let (x0, w, em) = group.unwrap_or((g.x as f64, advance, g.size as f64));
// Upright and horizontal, centred across the column in an em-high cell.
let centre = to_layer * Point::new(x0 - shift + em / 2.0, g.y as f64 - 0.35 * em);
origin = Point::new(centre.x - w / 2.0 + (g.x as f64 - x0), centre.y + 0.35 * em);
if line_glyphs.get(k + 1).is_none_or(|&n| !tcy(n)) {
shift += w - em;
group = None;
}
} else if doc.vertical {
let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright);
if upright {
// Keep the character upright around its centre.
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
let rc = ROT90 * c;
let t = Affine::translate((rc.x - c.x, rc.y - c.y));
path = t * path;
outline_xf = t * outline_xf;
} else {
// Roman characters turn on their side with the column.
path = ROT90 * path;
outline_xf = ROT90 * outline_xf;
}
}
let origin = to_layer * Point::new(g.x as f64, g.y as f64);
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
b[0] = b[0].min(origin.x + r.x0);
b[1] = b[1].min(origin.y + r.y0);
@ -278,6 +332,9 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
synth_bold: g.synth_bold,
size: g.size as f64,
run: g.run,
face: g.face,
gid: g.id,
outline_xf,
});
}
}
@ -438,6 +495,59 @@ mod tests {
assert_eq!(v.hit(p0.midpoint(p1)), 1);
}
#[test]
fn vertical_roman_rotates_and_tate_chu_yoko_sets_upright_groups() {
let wide = |g: &CharGlyph| {
let r = kurbo::Shape::bounding_box(&g.path);
(r.width(), r.height())
};
// "H" sideways in a vertical column: wider than tall (it is taller than wide upright).
let doc = TextDoc { text: "HI".into(), size: 40.0, vertical: true, ..Default::default() };
let v = layout_doc(&doc);
let (w, h) = wide(&v.glyphs[0]);
assert!(w > h, "rotated: {w}×{h}");
// Standard Vertical Roman Alignment keeps it upright.
let mut up = doc.clone();
up.apply_style_all(|s| s.vertical_roman_upright = true);
let (w, h) = wide(&layout_doc(&up).glyphs[0]);
assert!(h > w, "upright: {w}×{h}");
// CJK stays upright.
let cjk = layout_doc(&TextDoc { text: "日本".into(), size: 40.0, vertical: true, ..Default::default() });
assert!(cjk.glyphs.iter().all(|g| g.origin.x.is_finite()));
// Tate-Chu-Yoko on "12" in "A12B": the digits sit side by side on one row of the column
// (same y, increasing x) and the group takes one em of the column.
let mut t = TextDoc { text: "A12B".into(), size: 40.0, vertical: true, ..Default::default() };
let plain = layout_doc(&t);
t.apply_style(1..3, |s| s.tate_chu_yoko = true);
let tcy = layout_doc(&t);
let (one, two) = (&tcy.glyphs[1], &tcy.glyphs[2]);
assert!((one.origin.y - two.origin.y).abs() < 1e-6, "{:?} {:?}", one.origin, two.origin);
assert!(two.origin.x > one.origin.x + 5.0);
let (w, h) = wide(one);
assert!(h > w, "digits upright");
let w = one.advance + two.advance;
let moved = tcy.glyphs[3].origin.y - plain.glyphs[3].origin.y;
// (Within kerning, which no longer applies across the run boundaries.)
assert!((moved - (40.0 - w)).abs() < 2.5, "the group takes one em: moved {moved}, width {w}");
// The group is centred across the column (x of the column centre line).
let mid = (one.origin.x + two.origin.x + two.advance) / 2.0;
assert!((mid - plain.glyphs[0].origin.x - 0.35 * 40.0).abs() < 3.0, "{mid}");
}
#[test]
fn ltr_paragraph_direction_is_forced() {
// Arabic first: Left-to-Right keeps a left-to-right base (the Latin word stays left).
let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} abc";
let ltr = layout_doc(&TextDoc { text: text.into(), size: 30.0, ..Default::default() });
let mut r = TextDoc { text: text.into(), size: 30.0, ..Default::default() };
r.direction = effectcraft_keyframe::text_doc::Direction::Rtl;
let rtl = layout_doc(&r);
let x_of = |l: &TextLayout, c: char| l.glyphs.iter().find(|g| g.ch == c).map(|g| g.origin.x).unwrap();
let first_ar = '\u{0645}';
assert!(x_of(&ltr, 'a') > x_of(&ltr, first_ar), "LTR: Arabic run first, then abc");
assert!(x_of(&rtl, 'a') < x_of(&rtl, first_ar), "RTL: abc on the left");
}
#[test]
fn centered_point_text_straddles_origin() {
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };

125
crates/text/src/variable.rs Normal file
View File

@ -0,0 +1,125 @@
//! Variable fonts: a face's variation axes and glyph outlines at a point of its design space
//! (Animate Text ▸ Variable Font Axes).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::DrawSettings;
use skrifa::{GlyphId, MetadataProvider, Tag};
use crate::fonts::{self, FaceId};
use crate::{CharGlyph, Pen};
/// One variation axis of a face, in user units.
#[derive(Clone, Debug, PartialEq)]
pub struct FontAxis {
/// Four-letter tag (`wght`, `wdth`, `opsz`, `slnt`, `ital` or a custom one).
pub tag: String,
pub name: String,
pub min: f32,
pub default: f32,
pub max: f32,
}
/// The variation axes of a face (empty for static fonts).
pub fn font_axes(face: FaceId) -> Vec<FontAxis> {
let f = fonts::face(face);
let Some(font) = f.font() else { return Vec::new() };
font.axes()
.iter()
.map(|a| {
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
let name = font.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
FontAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value() }
})
.collect()
}
/// A tag string as a [`Tag`] (padded with spaces, at most four characters).
fn tag_of(s: &str) -> Tag {
let mut b = [b' '; 4];
for (i, c) in s.bytes().take(4).enumerate() {
b[i] = c;
}
Tag::new(&b)
}
type Key = (FaceId, u32, Vec<(String, i32)>);
/// A glyph's outline in font units at the given axis values (user units, unspecified axes at
/// their defaults).
pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<Arc<BezPath>> {
if coords.is_empty() {
return crate::outline_units(face, gid);
}
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<BezPath>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
// Quantise to 1/100 of a unit so animation frames reuse outlines.
let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect());
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return v.clone();
}
let f = fonts::face(face);
let v = f.font().and_then(|font| {
let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v)));
let g = font.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = Pen(BezPath::new());
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 20_000 {
m.clear();
}
m.insert(key, v.clone());
v
}
/// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes
/// moved by `deltas` (tag, user-unit offset from the axis default).
pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<BezPath> {
let axes = font_axes(g.face);
let coords: Vec<(String, f32)> =
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect();
if coords.is_empty() {
return None;
}
let units = outline_units_at(g.face, g.gid, &coords)?;
Some(g.outline_xf * (*units).clone())
}
/// The first installed face with variation axes (system fonts are scanned), for tests and
/// demos: (face, its axes).
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {
fonts::scan_system();
(0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min))
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
#[test]
fn static_fonts_have_no_axes_and_variable_outlines_move() {
let inter = crate::resolve("Inter", "Regular").face;
assert!(font_axes(inter).is_empty());
// A variable system font, when one is installed (macOS ships several): its outline
// changes along an axis. Skipped on machines without one.
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: outline check skipped");
return;
};
let a = axes.iter().find(|x| x.max > x.min).unwrap();
let gid = fonts::face(face).glyph('H').or_else(|| fonts::face(face).glyph('A')).unwrap_or(1);
let lo = outline_units_at(face, gid, &[(a.tag.clone(), a.min)]).unwrap();
let hi = outline_units_at(face, gid, &[(a.tag.clone(), a.max)]).unwrap();
assert_ne!(lo.area(), hi.area(), "{} {}..{}", a.tag, a.min, a.max);
// The default location is the static outline.
let d = outline_units_at(face, gid, &[(a.tag.clone(), a.default)]).unwrap();
let s = crate::outline_units(face, gid).unwrap();
assert!((d.area() - s.area()).abs() < 1e-3);
}
}

View File

@ -71,6 +71,8 @@ pub enum Dialog {
Marker,
/// Layer ▸ Pre-compose.
Precompose,
/// Layer ▸ Layer Styles ▸ Layer Style Options…
LayerStyles,
}
/// Host hooks provided by the native app (file pickers etc.).

View File

@ -153,6 +153,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer ▸ Layer Styles ▸ <style> from the menu adds the style and opens the dialog on it.
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
if let Some(rest) = id.strip_prefix("window.panel.") {
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
@ -452,6 +456,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.dialog = Some(crate::Dialog::About);
Value::Null
}
"layer.style.options" => {
crate::panels::layer_styles_dialog::open(app, &p)?;
Value::Null
}
"app.settings" => {
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));

View File

@ -85,6 +85,7 @@ pub struct CompDraft {
pub shutter_angle: f64,
pub shutter_phase: f64,
pub samples: u32,
pub adaptive_limit: u32,
pub advanced_3d: bool,
pub tab: Tab,
/// Timecode text being edited (start, duration).
@ -108,6 +109,7 @@ impl Default for CompDraft {
shutter_angle: 180.0,
shutter_phase: -90.0,
samples: 16,
adaptive_limit: 128,
advanced_3d: false,
tab: Tab::Basic,
start_tc: None,
@ -294,6 +296,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label("Samples Per Frame:");
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
ui.end_row();
ui.label("Adaptive Sample Limit:");
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
ui.end_row();
});
}
Tab::Renderer => {
@ -325,7 +330,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
json!({
"name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps,
"duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit,
"renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" },
})
}

View File

@ -42,6 +42,8 @@ pub struct DialogState {
pub marker: super::markers_ui::MarkerDraft,
/// Pre-compose dialog.
pub precompose: super::precomp::PrecomposeDraft,
/// Layer Style dialog.
pub layer_style: super::layer_styles_dialog::LayerStyleState,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
@ -66,6 +68,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
shutter_angle: c.shutter_angle,
shutter_phase: c.shutter_phase,
samples: c.motion_blur_samples,
adaptive_limit: c.motion_blur_adaptive_limit,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
..Default::default()
};
@ -123,6 +126,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
}

View File

@ -187,21 +187,59 @@ pub(crate) fn transform_box(
}
}
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
/// time within 6 points.
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time, another key's
/// time, a layer's in or out point, a comp or layer marker or the work area within 6 points.
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
if !app.ui.timeline.graph_snap {
return t;
}
let cti = app.session.time().seconds();
let mut cands = vec![app.session.time().seconds()];
cands.extend_from_slice(others);
if let Some(c) = app.session.active_comp() {
cands.extend(comp_snap_times(c));
}
snap_px(tm, t, &cands, 6.0)
}
/// Snap targets of a comp besides keys: work area, markers, and every layer's in / out points
/// and markers (comp seconds).
pub fn comp_snap_times(c: &Comp) -> Vec<f64> {
let mut v = vec![c.work_area.0.seconds(), c.work_area.1.seconds()];
v.extend(c.markers.iter().map(|m| m.time.seconds()));
for l in &c.layers {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
}
v
}
/// The candidate nearest `t` within `tol` screen points, else `t`.
pub(crate) fn snap_px(tm: TMap, t: f64, cands: &[f64], tol: f32) -> f64 {
let x = tm.x(t);
std::iter::once(cti)
.chain(others.iter().copied())
.map(|c| (c, (tm.x(c) - x).abs()))
.filter(|(_, d)| *d < 6.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(c, _)| c)
.unwrap_or(t)
cands.iter().copied().map(|c| (c, (tm.x(c) - x).abs())).filter(|(_, d)| *d < tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(c, _)| c).unwrap_or(t)
}
#[cfg(test)]
mod snap_tests {
use super::*;
#[test]
fn graph_snap_targets_markers_and_layer_ends() {
use effectcraft_engine::Session;
let mut s = Session::default();
s.execute("comp.new", json!({"name": "S", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap();
s.execute("comp.addMarker", json!({"time": 1.5})).unwrap();
let c = s.active_comp().unwrap();
let cands = comp_snap_times(c);
assert!(cands.contains(&1.5), "{cands:?}");
assert!(cands.iter().any(|t| (*t - c.layers[0].out_point.seconds()).abs() < 1e-9));
let tm = TMap { x0: 0.0, start: 0.0, pps: 100.0 };
// 1.53 s is 3 points from the marker: snaps; 1.6 s (10 points) doesn't.
assert_eq!(snap_px(tm, 1.53, &cands, 6.0), 1.5);
assert_eq!(snap_px(tm, 1.6, &[1.5], 6.0), 1.6);
}
}
// ---------------------------------------------------------------- timeline Alt-drag scaling

View File

@ -0,0 +1,226 @@
//! Layer ▸ Layer Styles ▸ Layer Style Options… (and choosing a style from that menu): a Layer
//! Style dialog with the style list on the left (a check box per style, Blending Options first)
//! and the selected style's controls on the right. Every control edits the ordinary
//! `layerStyles/<style>/<property>` property, so the viewer previews live; Cancel rolls the
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::styles::{BLENDING, STYLES};
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Dialog state: layer, selected list entry and the undo depth when it opened.
#[derive(Clone, Debug, Default)]
pub struct LayerStyleState {
pub layer: u64,
pub selected: String,
pub undo_mark: usize,
}
/// Open the dialog on the first selected (or given) layer, showing `style` (a style id;
/// Blending Options when none).
pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
let comp = app.session.active_comp().ok_or("no composition is open")?;
let layer = params
.get("layer")
.and_then(Value::as_u64)
.or_else(|| app.session.state.selected_layers.first().map(|l| l.0))
.filter(|l| comp.layer(LayerId(*l)).is_some())
.ok_or("select a layer first")?;
let style = params.get("style").and_then(Value::as_str).unwrap_or(BLENDING).to_string();
if style != BLENDING && !STYLES.iter().any(|s| s.0 == style) {
return Err(format!("unknown layer style `{style}`"));
}
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len() };
app.dialog = Some(Dialog::LayerStyles);
Ok(())
}
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ <style> items (no parameters) adds
/// the style as usual and opens the dialog on it.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
let Some(style) = id.strip_prefix("layer.style.").filter(|s| STYLES.iter().any(|x| x.0 == *s)) else { return Ok(false) };
if !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
let mark = app.session.history.undo.len();
app.session.execute(id, json!({})).map_err(|e| e.to_string())?;
open(app, &json!({"style": style}))?;
// Cancel also removes the style just added.
app.dialog_state.layer_style.undo_mark = mark;
Ok(true)
}
fn layer(app: &EffectcraftApp) -> Option<Layer> {
app.session.active_comp()?.layer(LayerId(app.dialog_state.layer_style.layer)).cloned()
}
/// One control row for a property. Returns the new value when it changed.
fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
match (v, &p.ui) {
(KV::Bool(b), _) => {
let mut on = *b;
ui.checkbox(&mut on, "").changed().then(|| json!(on))
}
(KV::Enum(i), ParamUi::Popup { options }) => {
let mut sel = *i as usize;
egui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
for (k, o) in options.iter().enumerate() {
ui.selectable_value(&mut sel, k, o);
}
});
(sel != *i as usize).then(|| json!(sel))
}
(KV::Scalar(x), ui_kind) => {
let mut y = *x;
let r = match ui_kind {
ParamUi::Slider { slider_min, slider_max, decimals, .. } => {
ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize))
}
ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")),
ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")),
ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")),
_ => ui.add(egui::DragValue::new(&mut y).speed(0.1)),
};
(r.changed() && y != *x).then(|| json!(y))
}
(KV::Color(c), _) => {
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32);
let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed();
changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()]))
}
(KV::Gradient(_), _) => {
ui.label(egui::RichText::new("Edit in the Timeline").weak());
None
}
_ => {
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
None
}
}
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some(l) = layer(app) else {
app.dialog = None;
return;
};
let lid = l.id.0;
let styles = l.layer_styles().cloned();
let sel = app.dialog_state.layer_style.selected.clone();
let time = app.session.time();
let lt = l.layer_time(time);
let mut actions: Vec<(String, Value)> = Vec::new();
let mut close: Option<bool> = None;
super::dialogs::modal(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui| {
ui.horizontal_top(|ui| {
// Style list.
ui.vertical(|ui| {
ui.set_width(200.0);
let mut entries = vec![(BLENDING, "Blending Options")];
entries.extend(STYLES.iter().copied());
for (id, name) in entries {
ui.horizontal(|ui| {
if id != BLENDING {
let g = styles.as_ref().and_then(|s| s.sub(id));
let mut on = g.is_some_and(|g| g.enabled);
if ui.checkbox(&mut on, "").changed() {
if g.is_none() {
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
} else {
actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on})));
}
}
} else {
ui.add_space(24.0);
}
let r = ui.selectable_label(sel == id, name);
if r.clicked() {
app.dialog_state.layer_style.selected = id.to_string();
}
let rect = r.rect;
app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name);
});
}
});
ui.separator();
// Controls of the selected style.
ui.vertical(|ui| {
let name = if sel == BLENDING { "Blending Options" } else { STYLES.iter().find(|s| s.0 == sel).map(|s| s.1).unwrap_or("") };
let head = ui.label(egui::RichText::new(name).font(Tokens::semibold(13.0)));
app.auto.add("dialog.layerStyle.page", head.rect, &sel);
ui.add_space(6.0);
let Some(g) = styles.as_ref().and_then(|s| s.sub(&sel)) else {
ui.label(egui::RichText::new("Not applied: tick it in the list to add it.").weak());
return;
};
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
g.walk("", &mut |_, p| {
if matches!(p.ui, ParamUi::Hidden) {
return;
}
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
if let Some(nv) = control(ui, p, &v) {
actions.push((
"prop.set".into(),
json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}),
));
}
let path = g.match_path_of(p.uid).unwrap_or_default();
app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name);
ui.end_row();
});
});
});
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.button("OK");
app.auto.add("dialog.layerStyle.ok", ok.rect, "OK");
if ok.clicked() {
close = Some(true);
}
let cancel = ui.button("Cancel");
app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel");
if cancel.clicked() {
close = Some(false);
}
let _ = Align2::LEFT_CENTER;
});
});
for (id, p) in actions {
if let Err(e) = app.session.execute(&id, p) {
app.ui.status = e.to_string();
}
}
match close {
Some(true) => finish(app, true),
Some(false) => finish(app, false),
None => {}
}
}
/// Show another entry of the list (`blendingOptions` or a style id).
pub fn select(app: &mut EffectcraftApp, style: &str) {
app.dialog_state.layer_style.selected = style.to_string();
}
/// Close the dialog: OK keeps the edits, Cancel undoes everything done since it opened.
pub fn finish(app: &mut EffectcraftApp, ok: bool) {
if !ok {
let mark = app.dialog_state.layer_style.undo_mark;
while app.session.history.undo.len() > mark {
if !app.session.undo() {
break;
}
}
}
app.dialog = None;
}

View File

@ -18,6 +18,7 @@ pub mod home;
pub mod info;
pub mod key_dialogs;
pub mod layer_panel;
pub mod layer_styles_dialog;
pub mod markers_ui;
pub mod misc;
pub mod paint_panels;

View File

@ -210,16 +210,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
let label_x = rect.min.x + name_w + 14.0;
let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
let mut x = label_x + 16.0;
// Optional columns wider than the panel scroll horizontally under the frozen Name and Label
// columns (Shift+wheel / trackpad over the list, or the scroll bar under it).
let opt_x0 = label_x + 16.0;
let natural_end = opt_x0 + app.ui.project_columns.iter().filter_map(|k| COLUMNS.iter().find(|c| c.0 == k)).map(|c| c.2 + 4.0).sum::<f32>();
let overflow = (natural_end - rect.max.x + 4.0).max(0.0);
app.ui.project_hscroll = app.ui.project_hscroll.clamp(0.0, overflow);
let hscroll = app.ui.project_hscroll;
let mut x = opt_x0 - hscroll;
for key in app.ui.project_columns.clone() {
if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) {
cols.push((k, l, x, *w));
x += w + 4.0;
}
}
let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
let hp = p.with_clip_rect(hdr);
for (key, label, x, w) in &cols {
let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
if hr.width() <= 0.0 {
continue;
}
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
if *key == "label" {
@ -252,6 +264,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer_h = 28.0;
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
let lp = p.with_clip_rect(list);
if overflow > 0.0 && ui.rect_contains_pointer(list) {
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
let d = if dx.abs() > 0.0 {
dx
} else if shift {
dy
} else {
0.0
};
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
}
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0);
let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height()));
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag());
p.rect_filled(track, 2.0, t.field_bg);
p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint });
app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}"));
if sresp.dragged() {
app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow);
}
}
let mut y = list.min.y;
let query = app.ui.project_search.to_lowercase();
let mut rows: Vec<(ItemId, usize)> = vec![];
@ -390,7 +425,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Optional columns.
for (key, _, cx, w) in cols.iter().skip(2) {
let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list);
let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
if cell.width() <= 0.0 {
continue;
}
let cp = lp.with_clip_rect(cell);
let txt = match *key {
"type" => it.type_name().to_string(),

View File

@ -232,6 +232,22 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("character.ligatures", lr, "Ligatures");
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim);
// Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu).
if doc.vertical {
y += 22.0;
for (i, (key, label, on)) in
[("tateChuYoko", "Tate-Chu-Yoko", doc.tate_chu_yoko), ("verticalRomanUpright", "Standard Vertical Roman", doc.vertical_roman_upright)]
.into_iter()
.enumerate()
{
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled {
actions.push(json!({key: !on}));
}
app.auto.add(&format!("character.{key}"), cr, label);
p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim);
}
}
match &target {
None => {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);

View File

@ -727,7 +727,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer_h = 24.0;
let vis = Vis::of(&app.ui.timeline);
let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0));
let cw = cols(rect.min.x, left_w, vis);
// Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline,
// or the scroll bar above the footer); the name column keeps its minimum width.
let natural_w = vis.fixed() + 190.0;
let overflow = (natural_w - left_w).max(0.0);
app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow);
let oscroll = app.ui.timeline.outline_scroll;
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
let graph_x0 = rect.min.x + left_w + 1.0;
let graph_x1 = rect.max.x - 10.0;
ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
@ -936,8 +942,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
let hy = ch.center().y;
let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
// Column headers scroll with the outline and never spill into the time ruler.
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
if vis.av {
hic(Icon::Eye, cw.av + 10.0);
hic(Icon::Speaker, cw.av + 28.0);
@ -948,7 +956,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
htext(cw.keys + 6.0, "Keys");
}
if vis.label {
icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
}
if vis.num {
htext(cw.num + 6.0, "#");
@ -971,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
htext(cw.trkmat + 4.0, "Track Matte");
}
if vis.parent {
icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
htext(cw.parent + 20.0, "Parent & Link");
}
for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] {
@ -1044,8 +1052,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if zoom && dy.abs() > 0.0 {
zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0));
} else {
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() {
let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0);
let shift = ui.input(|i| i.modifiers.shift);
if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) {
let d = if dx.abs() > 0.0 { dx } else { dy };
app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow);
} else {
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
}
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline {
app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0);
}
}
@ -1431,6 +1446,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
@ -1705,7 +1721,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ks = sel_keys.contains(&kref);
let icon = k.icon();
let c = pos2(x, cy);
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200));
// Labelled keys take their label colour (brighter when selected).
let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
Some(l) => {
let c = t.label(*l);
if ks { c } else { c.gamma_multiply(0.75) }
}
None if ks => t.keyframe_selected,
None => t.keyframe,
};
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
@ -1742,6 +1767,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
("Select All Keyframes", "keys.selectAll", json!({})),
("Delete", "keys.delete", json!({})),
] {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.menu_button("Label", |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() {
if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() {
if !ks {
actions.push((
"keys.select".into(),
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
));
}
actions.push(("keys.setLabel".into(), json!({"label": i})));
ui.close();
}
}
});
}
if lbl == "-" {
ui.separator();
continue;
@ -1876,6 +1918,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
p.rect_filled(footer, 0.0, t.panel_bg);
p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
if overflow > 0.0 {
// Outline scroll bar along the top of the footer, under the columns.
let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0));
let frac = left_w / natural_w;
let tw = (track.width() * frac).max(20.0);
let tx = track.min.x + (track.width() - tw) * (oscroll / overflow);
let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height()));
p.rect_filled(track, 2.0, t.field_bg);
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag());
p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint });
app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}"));
if sresp.dragged() {
let k = overflow / (track.width() - tw).max(1.0);
app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow);
}
}
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click());
p.text(tgl.left_center(), Align2::LEFT_CENTER, "Toggle Switches / Modes", Tokens::ui(11.0), if tresp.hovered() { t.text } else { t.text_dim });
@ -2087,6 +2145,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
}
}
/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
#[allow(clippy::too_many_arguments)]
fn dash_buttons(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
return;
}
let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return };
let t = app.tokens;
for (i, (sym, id, tip)) in
[("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate()
{
let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip);
if resp.clicked() {
actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke})));
}
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(
@ -2135,6 +2224,19 @@ fn text_anim_popups(
opts.push(l.to_string());
acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) });
}
// Variable Font Axes of the layer's font.
if let Some(effectcraft_engine::keyframe::Value::Text(doc)) = layer.props.prop("text/sourceText").map(|p| p.value.clone()) {
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let axes = effectcraft_engine::text::variable::font_axes(face);
if !axes.is_empty() {
opts.push("-".into());
acts.push((String::new(), json!(null)));
}
for a in axes {
opts.push(format!("Variable Font Axes: {}", a.name));
acts.push(("text.animatorFontAxes".into(), json!({"layer": layer.id.0, "axis": a.tag})));
}
}
} else {
for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] {
opts.push(l.into());

View File

@ -156,6 +156,32 @@ thread_local! {
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = const { std::cell::Cell::new(None) };
}
/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge
/// midpoints (4 top, 5 right, 6 bottom, 7 left).
fn roi_handles(r: Rect) -> [Pos2; 8] {
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()]
}
/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or
/// edge, with the other axis kept).
fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option<ov::Drag> {
let [rx, ry, rw, rh] = app.session.state.region_of_interest?;
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?;
let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh);
let _ = map;
Some(match i {
0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] },
1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] },
2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] },
3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] },
4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] },
5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] },
6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] },
_ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] },
})
}
/// The camera the viewer shows (view camera or the comp's active camera).
fn cam_state(ctx: &EvalCtx) -> CameraState {
VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx))
@ -467,11 +493,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
// Resize handles: corners and edge midpoints (drag with any tool).
for (i, c) in roi_handles(r).iter().enumerate() {
painter.rect_filled(Rect::from_center_size(*c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
app.auto.add(&format!("viewer.regionOfInterest.handle.{i}"), Rect::from_center_size(*c, vec2(8.0, 8.0)), "Resize the region of interest");
}
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
}
// 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera.
if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) {
let cam = cam_state(&ectx);
let o = pos2(area.min.x + 40.0, area.max.y - 40.0);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"),
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"),
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"),
] {
let v = cam.view.apply_vec(axis);
let tip = o + vec2(v.x as f32, v.y as f32) * 26.0;
painter.line_segment([o, tip], Stroke::new(2.0, col));
painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
}
app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes");
}
// Overlays + interaction.
let selected = app.session.state.selected_layers.clone();
@ -702,7 +746,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let g = match tool {
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
// Region of Interest armed: the drag draws it.
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })),
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press), keep: [None, None] })),
// A region of interest handle: resize it from the opposite corner / edge.
_ if roi_drag(app, &map, comp_rect, zoom, press).is_some() => roi_drag(app, &map, comp_rect, zoom, press).map(Gesture::Overlay),
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
}
@ -741,7 +787,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
layer: l,
start_anchor: ectx.v3(layer, tr, "anchor", [0.0; 3]),
start_pos: ectx.v3(layer, tr, "position", [0.0; 3]),
start: cpt,
// From where the button went down (the drag starts a few points later).
start: map.to_comp(press),
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
l2p,
})
@ -914,6 +961,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
}
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => {
// Pan Behind snaps the anchor point (to layer features, guides and the grid).
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]];
let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods);
let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]];
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(cpt[0], cpt[1]));
let dl = gv2(b.x - a.x, b.y - a.y);
@ -958,8 +1010,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
}
ov::Drag::Roi { start } => {
let r = Rect::from_two_pos(map.to_screen(start), pos);
ov::Drag::Roi { start, keep } => {
let e = [keep[0].unwrap_or(cpt[0]), keep[1].unwrap_or(cpt[1])];
let r = Rect::from_two_pos(map.to_screen(start), map.to_screen(e));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
}
d => {
@ -1015,8 +1068,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods.shift);
}
Gesture::Overlay(ov::Drag::Roi { start }) => {
let end = map.to_comp(pos);
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
let c = map.to_comp(pos);
let end = [keep[0].unwrap_or(c[0]), keep[1].unwrap_or(c[1])];
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {

View File

@ -41,6 +41,8 @@ pub(crate) struct PathInfo {
pub m: Mat3,
pub sp: ShapePath,
pub is_mask: bool,
/// Mask feather points' radius handles on screen (feather point index, handle position).
pub feather: Vec<(usize, Pos2)>,
}
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
@ -93,7 +95,7 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
let (m, _) = super::viewer::l2c(ectx, l);
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
Some(PathInfo { layer, uid, m, sp, is_mask })
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
@ -178,10 +180,55 @@ pub(crate) fn draw_paths(
{
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
let feather = if is_mask { draw_feather_points(painter, map, &sp, &pm, mc) } else { Vec::new() };
for (i, h) in &feather {
app.auto.add(&format!("viewer.mask.{uid}.feather.{i}"), Rect::from_center_size(*h, vec2(8.0, 8.0)), &name);
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask, feather });
}
}
/// Mask feather points: a hollow dot on the path and a radius handle along the normal (outwards for
/// outer feather, inwards for inner), joined by a line. Returns the handles' screen positions.
fn draw_feather_points(painter: &egui::Painter, map: &ViewerMap, sp: &ShapePath, pm: &Mat3, col: Color32) -> Vec<(usize, Pos2)> {
let mut out = Vec::new();
let scr = |p: [f64; 2]| {
let c = pm.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
};
let inside = |q: [f64; 2]| {
// Even-odd test against the vertex polygon (good enough to orient the handle).
let v = &sp.vertices;
let mut c = false;
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) + a[0] {
c = !c;
}
}
c
};
for (i, f) in sp.feather.iter().enumerate() {
let Some(c) = segment(sp, f.segment) else { continue };
let at = cubic(c[0], c[1], c[2], c[3], f.t);
let (t0, t1) = ((f.t - 1e-3).max(0.0), (f.t + 1e-3).min(1.0));
let (a, b) = (cubic(c[0], c[1], c[2], c[3], t0), cubic(c[0], c[1], c[2], c[3], t1));
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy).max(1e-9);
let mut n = [-dy / len, dx / len];
if inside([at[0] + n[0] * 2.0, at[1] + n[1] * 2.0]) {
n = [-n[0], -n[1]];
}
let h = [at[0] + n[0] * f.radius, at[1] + n[1] * f.radius];
let (ps, hs) = (scr(at), scr(h));
painter.line_segment([ps, hs], Stroke::new(1.0, col));
painter.circle_stroke(ps, 3.5, Stroke::new(1.0, col));
painter.circle_filled(hs, 3.0, col);
out.push((i, hs));
}
out
}
// ---------------------------------------------------------------- motion paths
/// A keyframe dot or spatial tangent handle on a motion path.
@ -382,12 +429,12 @@ pub(crate) enum Drag {
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
/// Mask Feather tool: drag away from the path to feather the mask.
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
/// Mask Feather tool: drag a feather point's radius handle (comp → layer matrix `inv`).
Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 },
/// A guide dragged in the viewer.
Guide { index: usize, vertical: bool },
/// Drawing the region of interest.
Roi { start: [f64; 2] },
/// Drawing the region of interest from `start`; `keep` pins the end's x / y (edge handles).
Roi { start: [f64; 2], keep: [Option<f64>; 2] },
}
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<D
None
}
/// Mask Feather tool: start on a mask path.
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
let l = ectx.comp.layer(layer)?;
let pr = l.masks()?.find_group(uid)?.get("feather")?;
let _ = app;
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
/// Mask Feather tool: press on a feather point's handle to drag it, or on a mask path to add a
/// feather point there and drag its radius out.
pub(crate) fn begin_feather(app: &mut EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let _ = ectx;
for p in paths.iter().filter(|p| p.is_mask) {
if let Some((i, _)) = p.feather.iter().find(|(_, h)| h.distance(press) < 7.0) {
return Some(Drag::Feather { layer: p.layer, mask: p.uid, index: *i, inv: p.m.inverse()? });
}
}
let masks: Vec<PathInfo> = paths.iter().filter(|p| p.is_mask).cloned().collect();
let (layer, uid, seg, t) = segment_at(&masks, map, press, 10.0)?;
let inv = masks.iter().find(|p| p.layer == layer && p.uid == uid)?.m.inverse()?;
let r = app.session.execute("mask.featherPoint.add", json!({"layer": layer.0, "mask": uid, "segment": seg, "t": t, "radius": 0}));
match r {
Ok(v) => Some(Drag::Feather { layer, mask: uid, index: v["index"].as_u64()? as usize, inv }),
Err(e) => {
app.ui.status = e.to_string();
None
}
}
}
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
@ -513,10 +573,13 @@ pub(crate) fn update(
}
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
}
Drag::Feather { layer, prop, start, press } => {
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
Drag::Feather { layer, mask, index, inv } => {
let p = inv.apply(gv2(cpt[0], cpt[1]));
let _ = (pos, zoom);
let q = json!({"layer": layer.0, "mask": mask, "index": index, "toward": [p.x, p.y], "merge": merge});
if let Err(e) = app.session.execute("mask.featherPoint.set", q) {
app.ui.status = e.to_string();
}
None
}
Drag::Guide { index, .. } => {

View File

@ -54,6 +54,8 @@ struct Edited {
/// Layer → comp.
m: Mat3,
inv: Mat3,
/// Per caret position: static layout → animated / on-path character placement.
carets: Vec<Mat3>,
}
impl Edited {
@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited>
let lay = layout_doc(&doc);
let m = super::viewer::l2c(ectx, &layer).0;
let inv = m.inverse()?;
Some(Edited { layer, doc, lay, m, inv })
let carets = effectcraft_engine::render::text::caret_maps(ectx, &layer, lay.chars);
Some(Edited { layer, doc, lay, m, inv, carets })
}
/// The topmost text layer whose text is under screen point `s`.
@ -494,7 +497,14 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma
}
}
// Caret (blinking) and IME composition.
// The caret follows the animated layout and text on a path.
let (a, b) = e.lay.caret(caret);
let cm = e.carets.get(caret).copied().unwrap_or(Mat3::IDENTITY);
let anim = |p: Point| {
let q = cm.apply(gv2(p.x, p.y));
Point::new(q.x, q.y)
};
let (a, b) = (anim(a), anim(b));
let (sa, sb) = (to_s(a), to_s(b));
let now = ui.input(|i| i.time);
let preedit: Option<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));

View File

@ -585,6 +585,8 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
("Grid".to_string(), v.grid),
("Guides".to_string(), v.guides),
("Rulers".to_string(), v.rulers),
("-".to_string(), false),
("3D Reference Axes".to_string(), app.session.prefs.three_d.show_reference_axes),
];
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
let v = &mut app.ui.viewer;
@ -593,7 +595,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
1 => v.proportional_grid = !v.proportional_grid,
2 => v.grid = !v.grid,
3 => v.guides = !v.guides,
_ => v.rulers = !v.rulers,
4 => v.rulers = !v.rulers,
6 => app.session.prefs.three_d.show_reference_axes = !app.session.prefs.three_d.show_reference_axes,
_ => {}
}
}
// Show Channel and Color Management Settings.

View File

@ -340,6 +340,9 @@ pub struct TimelineState {
pub scroll_y: f32,
/// Left column area width.
pub columns_w: f32,
/// Horizontal scroll of the outline columns (pixels) when they are wider than the pane.
#[serde(default)]
pub outline_scroll: f32,
pub show_modes: bool,
pub graph_editor: bool,
pub search: String,
@ -403,6 +406,7 @@ impl Default for TimelineState {
pps: None,
scroll_y: 0.0,
columns_w: 560.0,
outline_scroll: 0.0,
show_modes: false,
graph_editor: false,
search: String::new(),
@ -545,6 +549,9 @@ pub struct UiState {
/// `duration`, `path`, `comment`); toggled from the column header's context menu.
#[serde(default = "default_project_columns")]
pub project_columns: Vec<String>,
/// Horizontal scroll of the Project panel's optional columns (Name and Label stay put).
#[serde(default)]
pub project_hscroll: f32,
/// Effect Controls twirl state (group uids that are collapsed).
pub fx_closed: BTreeSet<u64>,
/// Slider params whose slider row is twirled open (AE hides sliders by default).
@ -631,6 +638,7 @@ impl Default for UiState {
project_open_folders: BTreeSet::new(),
project_sort: default_project_sort(),
project_sort_desc: false,
project_hscroll: 0.0,
project_columns: default_project_columns(),
fx_closed: BTreeSet::new(),
fx_slider_open: BTreeSet::new(),

View File

@ -0,0 +1,122 @@
//! Headless checks for the M13.5 long-tail UI: horizontal scrolling of the Timeline outline and
//! Project panel columns (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness(w: f32) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(w, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
#[test]
fn timeline_outline_columns_scroll_horizontally() {
let mut h = harness(1100.0);
// Every column on: wider than the outline pane.
let ctx = h.ctx.clone();
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": true})).unwrap();
}
h.run_steps(3);
let bar = rect(&h, "timeline.outlineScroll");
let name0 = rect(&h, "timeline.header.name").min.x;
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
drag(&mut h, pos2(bar.min.x + 8.0, bar.center().y), pos2(bar.max.x + 50.0, bar.center().y));
h.run_steps(2);
let s = h.state().ui.timeline.outline_scroll;
assert!(s > 50.0, "{s}");
let name1 = rect(&h, "timeline.header.name").min.x;
assert!((name0 - name1 - s).abs() < 1.0, "the columns moved by the scroll: {name0} → {name1} ({s})");
// The scroll is clamped to the overflow and resets when the columns fit again.
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": false})).unwrap();
}
h.run_steps(3);
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
assert!(h.state().auto.find("timeline.outlineScroll").is_none());
}
fn styles(h: &mut Harness<'_, EffectcraftApp>) -> Vec<String> {
let v = h.state_mut().session.execute("layer.style.list", json!({})).unwrap();
v["styles"].as_array().unwrap().iter().map(|s| s["style"].as_str().unwrap().to_string()).collect()
}
#[test]
fn layer_style_dialog_adds_previews_and_cancels() {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
// Layer ▸ Layer Styles ▸ Drop Shadow (from the menu): adds the style and opens the dialog on it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
assert_eq!(styles(&mut h), ["dropShadow"]);
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_some(), "the style's controls are shown");
// Live preview: the controls edit the real property (here through the same command).
h.state_mut().session.execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 25, "merge": "x"})).unwrap();
// Pick Stroke in the list (its page: not applied), then Cancel: everything done in the
// dialog is rolled back. (Dialog buttons are driven through the dialog's functions:
// kittest pointer events don't reach Foreground areas here.)
effectcraft_ui_egui::panels::layer_styles_dialog::select(h.state_mut(), "stroke");
h.run_steps(2);
assert_eq!(h.state().auto.find("dialog.layerStyle.page").unwrap().label, "stroke");
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_none());
assert!(h.state().auto.find("dialog.layerStyle.cancel").is_some());
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(styles(&mut h).is_empty(), "Cancel removes the style added from the menu");
// Layer Style Options… on Stroke, add it (the list check box runs layer.style.add), OK keeps it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_none(), "not applied yet");
h.state_mut().session.execute("layer.style.add", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_some(), "its controls appear");
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
assert!(h.state().dialog.is_none());
assert_eq!(styles(&mut h), ["stroke"]);
}
#[test]
fn project_panel_columns_scroll_horizontally() {
let mut h = harness(1400.0);
for c in ["type", "size", "duration", "fps", "path", "comment"] {
effectcraft_ui_egui::panels::project::set_column(&mut h.state_mut().ui.project_columns, c, true);
}
h.run_steps(3);
let bar = rect(&h, "project.hscroll");
let x0 = rect(&h, "project.sort.comment").min.x;
let name0 = rect(&h, "project.sort.name").min.x;
drag(&mut h, pos2(bar.min.x + 4.0, bar.center().y), pos2(bar.max.x + 40.0, bar.center().y));
h.run_steps(2);
let s = h.state().ui.project_hscroll;
assert!(s > 20.0, "{s}");
assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0);
assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen");
}

View File

@ -295,3 +295,108 @@ fn timeline_alt_drag_scales_a_key_group_in_time() {
assert_eq!(ts[0], 0.0);
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
}
#[test]
fn mask_feather_tool_adds_and_drags_feather_points() {
let mut h = harness();
// A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70.
let (box_id, mask) = {
let s = &mut h.state_mut().session;
let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"]
.as_u64()
.unwrap();
s.execute("layer.select", json!({"layers": [b.0]})).unwrap();
(b, m)
};
h.state_mut().ui.tool = Tool::MaskFeather;
h.run_steps(3);
// Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px.
let from = screen(&h, [320.0, 150.0]);
let z = rect(&h, "viewer.comp").width() / 640.0;
drag(&mut h, from, from - vec2(0.0, 12.0 * z));
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r - 12.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"][0]["segment"], json!(0));
// The handle is registered; dragging it inwards makes it an inner feather point.
h.run_steps(2);
let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center();
let to = screen(&h, [320.0, 160.0]);
drag(&mut h, handle, to);
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r + 10.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"].as_array().unwrap().len(), 1);
// One drag = one undo step each.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0);
}
#[test]
fn region_of_interest_resizes_by_its_handles() {
let mut h = harness();
h.state_mut().session.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 200]})).unwrap();
h.run_steps(3);
// Bottom-right corner (handle 2) to (400, 300): the top-left stays.
let c = rect(&h, "viewer.regionOfInterest.handle.2").center();
let to = screen(&h, [400.0, 300.0]);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 300.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
// The left edge (handle 7) only moves x.
h.run_steps(2);
let c = rect(&h, "viewer.regionOfInterest.handle.7").center();
let to = pos2(screen(&h, [150.0, 0.0]).x, c.y + 40.0);
drag(&mut h, c, to);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 150.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 250.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
}
#[test]
fn pan_behind_snaps_the_anchor_point() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
h.state_mut().ui.tool = Tool::PanBehind;
h.run_steps(2);
// Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the
// position follows so the box doesn't move.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
// (The gesture applies the pointer of the previous frame: hold the end point a frame.)
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=10 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
let a = l.props.prop("transform/anchor").unwrap().value.as_vec3();
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}");
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}");
}
#[test]
fn reference_axes_toggle_from_the_grid_menu() {
let mut h = harness();
// A 3D layer makes the axes relevant.
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)");
let g = rect(&h, "viewer.grid").center();
click(&mut h, g);
h.run_steps(2);
let item = rect(&h, "viewer.gridItem.6").center();
click(&mut h, item);
h.run_steps(3);
assert!(!h.state().session.prefs.three_d.show_reference_axes);
assert!(h.state().auto.find("viewer.referenceAxes").is_none());
}

View File

@ -115,16 +115,16 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 76% | 10.0 | multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 80% | 4.5 | GPU versions of more effects and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -152,6 +152,21 @@ Mocha and Cineware are third-party and not counted.
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
| Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
## Update: M13.5 long-tail polish
Stroke Taper / Wave / multi-segment dashes and the radial gradient highlight (SHP-2);
variable-width mask feather points (MSK-1; mask motion blur verified by tests); the Adaptive
Sample Limit driving per-layer motion-blur samples (LYR-10); Classic 3D depth of field with
the camera's iris shape, rotation, roundness, aspect ratio, diffraction fringe and highlights,
progressive blur on tilted layers, and Link Focus Distance to Point of Interest / to Layer and
Set Focus Distance to Layer (3D-1); Timeline outline and Project panel column scrolling;
keyframe colour labels and Select Keyframe Label Group (ANM-1); vertical Roman text,
Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text and Lottie
style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends
(ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances.
## Highest-value gaps, in order
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).