Files
effectcraft/crates/keyframe/src/value.rs
T
Brandon ThomasandClaude Opus 5.5 951ed1fa3d M5.2–M5.5: keyframe clipboard, easy ease, velocity/interpolation dialogs, roving, time remap/stretch/reverse/freeze, graph editor, separate dimensions, pick-whip
Engine (all commands with undo/redo + tests in engine/src/tests_timeline.rs):
- keys.copy/paste (Edit Copy/Cut/Paste route to keys when keys are selected;
  relative timing kept, paste at CTI, cross-property paste of same type),
  keys.selectAll (Cmd+Alt+A), keys.nudge* (Alt+Arrow, Alt+Shift+Arrow),
  keys.set / keys.setEase (graph editor drags), keys.info (dialog data),
  keys.timeReverse; keys.interpolation gains continuous/auto Bezier, spatial
  interpolation and roving; keys.velocity takes per-dimension values and
  Continuous.
- Roving keys (keyframe::retime_roving): constant speed along the motion path.
- Layer > Time: layer.timeStretch (percent|duration, hold in/current/out),
  layer.timeReverse (Cmd+Alt+R), layer.enableTimeRemap (Cmd+Alt+T),
  layer.freezeFrame, layer.freezeOnLastFrame; render uses Time Remap as the
  source time for precomp/footage layers.
- prop.separateDimensions (X/Y/Z Position; prop.set on Position writes them).
- layer.setParent compensates so the layer stays in place (compensate:false
  to opt out).
- prop.pickWhip writes AE-style reference expressions (thisComp.layer(..),
  effect(..)(..), mask(..).maskFeather, temp=…;[temp,temp], …[0]).
- prop.setExpression: syntax errors keep the text but disable the expression.
- expr: arrays of properties returned by an expression are resolved.

UI: Keyframe Velocity, Keyframe Interpolation and Time Stretch dialogs;
Graph Editor (value + speed graphs, draggable keys and Bezier/influence
handles, auto-zoom, fit, show selected, separate dimensions, hold/linear/
auto Bezier/Easy Ease buttons); key + layer context menus. Clears the
workspace clippy -D warnings failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:47:29 -04:00

376 lines
13 KiB
Rust

//! Property values.
use serde::{Deserialize, Serialize};
/// A Bezier shape in the After Effects / Lottie vertex model: vertices with tangents relative to
/// their vertex.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ShapePath {
pub vertices: Vec<[f64; 2]>,
pub in_tangents: Vec<[f64; 2]>,
pub out_tangents: Vec<[f64; 2]>,
pub closed: bool,
}
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 }
}
pub fn len(&self) -> usize {
self.vertices.len()
}
pub fn is_empty(&self) -> bool {
self.vertices.is_empty()
}
/// Rectangle centred at `c` (clockwise from top-left, as AE builds mask rectangles).
pub fn rect(c: [f64; 2], w: f64, h: f64) -> ShapePath {
let (x0, y0, x1, y1) = (c[0] - w / 2.0, c[1] - h / 2.0, c[0] + w / 2.0, c[1] + h / 2.0);
ShapePath::polygon(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true)
}
/// Ellipse with four Bezier arcs (kappa 0.5523).
pub fn ellipse(c: [f64; 2], w: f64, h: f64) -> ShapePath {
const K: f64 = 0.552_284_749_8;
let (rx, ry) = (w / 2.0, h / 2.0);
ShapePath {
vertices: vec![[c[0], c[1] - ry], [c[0] + rx, c[1]], [c[0], c[1] + ry], [c[0] - rx, c[1]]],
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,
}
}
fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath {
if self.len() != o.len() {
return if t < 1.0 { self.clone() } else { o.clone() };
}
let l = |a: &[[f64; 2]], b: &[[f64; 2]]| a.iter().zip(b).map(|(p, q)| [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]).collect();
ShapePath {
vertices: l(&self.vertices, &o.vertices),
in_tangents: l(&self.in_tangents, &o.in_tangents),
out_tangents: l(&self.out_tangents, &o.out_tangents),
closed: self.closed,
}
}
}
/// Horizontal paragraph alignment.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
#[default]
Left,
Center,
Right,
JustifyLastLeft,
JustifyLastCenter,
JustifyLastRight,
JustifyAll,
}
/// The value of a text layer's Source Text (hold-interpolated).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextDoc {
pub text: String,
pub font: String,
pub style: String,
pub size: f64,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub apply_fill: bool,
pub apply_stroke: bool,
pub stroke_over_fill: bool,
pub tracking: f64,
/// None = auto (120% of size).
pub leading: Option<f64>,
pub justify: Justify,
pub baseline_shift: f64,
pub h_scale: f64,
pub v_scale: f64,
pub faux_bold: bool,
pub faux_italic: bool,
pub all_caps: bool,
pub small_caps: bool,
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
pub box_size: Option<[f64; 2]>,
pub box_pos: [f64; 2],
}
impl Default for TextDoc {
fn default() -> Self {
TextDoc {
text: String::new(),
font: "Inter".into(),
style: "Regular".into(),
size: 72.0,
fill: [1.0, 1.0, 1.0, 1.0],
stroke: [0.0, 0.0, 0.0, 1.0],
stroke_width: 0.0,
apply_fill: true,
apply_stroke: false,
stroke_over_fill: true,
tracking: 0.0,
leading: None,
justify: Justify::Left,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
faux_bold: false,
faux_italic: false,
all_caps: false,
small_caps: false,
box_size: None,
box_pos: [0.0, 0.0],
}
}
}
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Gradient {
pub colors: Vec<(f64, [f32; 4])>,
pub opacities: Vec<(f64, f32)>,
}
impl Default for Gradient {
fn default() -> Self {
Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }
}
}
impl Gradient {
pub fn sample(&self, t: f64) -> [f32; 4] {
let t = t.clamp(0.0, 1.0);
let col = sample_stops(&self.colors, t, |a, b, f| {
let mut o = [0.0; 4];
for i in 0..4 {
o[i] = a[i] + (b[i] - a[i]) * f as f32;
}
o
})
.unwrap_or([1.0; 4]);
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
[col[0], col[1], col[2], col[3] * a]
}
}
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
let first = stops.first()?;
if t <= first.0 {
return Some(first.1);
}
for w in stops.windows(2) {
if t <= w[1].0 {
let span = (w[1].0 - w[0].0).max(1e-12);
return Some(lerp(w[0].1, w[1].1, (t - w[0].0) / span));
}
}
stops.last().map(|s| s.1)
}
/// The value of a property.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t", content = "v")]
pub enum Value {
Scalar(f64),
Vec2([f64; 2]),
Vec3([f64; 3]),
/// Straight RGBA 0..1.
Color([f64; 4]),
Bool(bool),
/// Index into a popup's options (1-based like AE's dropdown values is a UI concern; this is 0-based).
Enum(u32),
Path(ShapePath),
Text(Box<TextDoc>),
/// Layer reference (layer id), e.g. Layer Control effects, track matte sources.
Layer(Option<u64>),
Gradient(Gradient),
Str(String),
}
impl Value {
/// Numeric components (empty for non-numeric values).
pub fn components(&self) -> Vec<f64> {
match self {
Value::Scalar(v) => vec![*v],
Value::Vec2(v) => v.to_vec(),
Value::Vec3(v) => v.to_vec(),
Value::Color(v) => v.to_vec(),
Value::Bool(b) => vec![if *b { 1.0 } else { 0.0 }],
Value::Enum(i) => vec![*i as f64],
_ => vec![],
}
}
pub fn dims(&self) -> usize {
match self {
Value::Scalar(_) | Value::Bool(_) | Value::Enum(_) => 1,
Value::Vec2(_) => 2,
Value::Vec3(_) => 3,
Value::Color(_) => 4,
_ => 0,
}
}
/// Rebuild a value of the same kind from components.
pub fn with_components(&self, c: &[f64]) -> Value {
let g = |i: usize| c.get(i).copied().unwrap_or(0.0);
match self {
Value::Scalar(_) => Value::Scalar(g(0)),
Value::Vec2(_) => Value::Vec2([g(0), g(1)]),
Value::Vec3(_) => Value::Vec3([g(0), g(1), g(2)]),
Value::Color(_) => Value::Color([g(0), g(1), g(2), g(3)]),
Value::Bool(_) => Value::Bool(g(0) >= 0.5),
Value::Enum(_) => Value::Enum(g(0).round().max(0.0) as u32),
v => v.clone(),
}
}
/// Can be interpolated (otherwise keyframes behave as hold).
pub fn interpolates(&self) -> bool {
matches!(self, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) | Value::Path(_) | Value::Gradient(_))
}
pub fn lerp(&self, o: &Value, t: f64) -> Value {
match (self, o) {
(Value::Path(a), Value::Path(b)) => Value::Path(a.lerp(b, t)),
(Value::Gradient(a), Value::Gradient(b)) if a.colors.len() == b.colors.len() && a.opacities.len() == b.opacities.len() => {
let colors = a
.colors
.iter()
.zip(&b.colors)
.map(|(x, y)| {
let mut c = [0.0; 4];
for i in 0..4 {
c[i] = x.1[i] + (y.1[i] - x.1[i]) * t as f32;
}
(x.0 + (y.0 - x.0) * t, c)
})
.collect();
let opacities = a.opacities.iter().zip(&b.opacities).map(|(x, y)| (x.0 + (y.0 - x.0) * t, x.1 + (y.1 - x.1) * t as f32)).collect();
Value::Gradient(Gradient { colors, opacities })
}
_ if self.interpolates() && self.dims() > 0 && self.dims() == o.dims() => {
let a = self.components();
let b = o.components();
self.with_components(&a.iter().zip(&b).map(|(x, y)| x + (y - x) * t).collect::<Vec<_>>())
}
_ => {
if t < 1.0 {
self.clone()
} else {
o.clone()
}
}
}
}
pub fn as_f64(&self) -> f64 {
self.components().first().copied().unwrap_or(0.0)
}
pub fn as_vec2(&self) -> [f64; 2] {
let c = self.components();
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0)]
}
pub fn as_vec3(&self) -> [f64; 3] {
let c = self.components();
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)]
}
pub fn as_color(&self) -> [f32; 4] {
let c = self.components();
let g = |i: usize, d: f64| c.get(i).copied().unwrap_or(d) as f32;
[g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]
}
pub fn as_bool(&self) -> bool {
match self {
Value::Bool(b) => *b,
v => v.as_f64() != 0.0,
}
}
pub fn as_enum(&self) -> u32 {
match self {
Value::Enum(i) => *i,
v => v.as_f64().max(0.0) as u32,
}
}
pub fn as_path(&self) -> Option<&ShapePath> {
if let Value::Path(p) = self { Some(p) } else { None }
}
pub fn as_text(&self) -> Option<&TextDoc> {
if let Value::Text(t) = self { Some(t) } else { None }
}
pub fn as_layer(&self) -> Option<u64> {
if let Value::Layer(l) = self { *l } else { None }
}
pub fn kind_name(&self) -> &'static str {
match self {
Value::Scalar(_) => "scalar",
Value::Vec2(_) => "vec2",
Value::Vec3(_) => "vec3",
Value::Color(_) => "color",
Value::Bool(_) => "bool",
Value::Enum(_) => "enum",
Value::Path(_) => "path",
Value::Text(_) => "text",
Value::Layer(_) => "layer",
Value::Gradient(_) => "gradient",
Value::Str(_) => "string",
}
}
/// Lenient conversion from JSON (numbers, arrays, booleans, strings) to a value shaped like `self`.
pub fn coerce_json(&self, j: &serde_json::Value) -> Option<Value> {
use serde_json::Value as J;
let nums = |j: &J| -> Option<Vec<f64>> {
match j {
J::Number(n) => n.as_f64().map(|v| vec![v]),
J::Array(a) => a.iter().map(J::as_f64).collect(),
J::Bool(b) => Some(vec![if *b { 1.0 } else { 0.0 }]),
_ => None,
}
};
match self {
Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Enum(_) => {
let mut c = nums(j)?;
if c.len() == 1 && self.dims() > 1 {
c = vec![c[0]; self.dims()];
}
Some(self.with_components(&c))
}
Value::Color(_) => {
if let Some(s) = j.as_str() {
let c = effectcraft_color::Rgba::from_hex(s)?;
return Some(Value::Color([c.r as f64, c.g as f64, c.b as f64, c.a as f64]));
}
let mut c = nums(j)?;
if c.len() == 3 {
c.push(1.0);
}
Some(self.with_components(&c))
}
Value::Bool(_) => j.as_bool().or_else(|| j.as_f64().map(|v| v != 0.0)).map(Value::Bool),
Value::Str(_) => j.as_str().map(|s| Value::Str(s.to_string())),
Value::Text(t) => {
if let Some(s) = j.as_str() {
let mut t = (**t).clone();
t.text = s.to_string();
Some(Value::Text(Box::new(t)))
} else {
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
}
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
Value::Path(_) => serde_json::from_value::<ShapePath>(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::<Value>(j.clone()).ok()),
_ => serde_json::from_value::<Value>(j.clone()).ok(),
}
}
/// JSON for display/automation (numbers/arrays for numeric kinds).
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
match self {
Value::Scalar(v) => json!(v),
Value::Vec2(v) => json!(v),
Value::Vec3(v) => json!(v),
Value::Color(v) => json!(v),
Value::Bool(b) => json!(b),
Value::Enum(i) => json!(i),
Value::Str(s) => json!(s),
Value::Layer(l) => json!(l),
Value::Text(t) => json!(t.text),
v => serde_json::to_value(v).unwrap_or_default(),
}
}
}