Files
Brandon ThomasandClaude Opus 5.5 1da7dc4659 Never crash: Lottie enum values can't overflow
Lottie stores line cap, line join, merge mode and some text-selector enums 1-based, and the
importer subtracted 1 from whatever number the file held: -1e308 (rounded to i64::MIN)
overflowed, a panic in debug builds (found by structural fuzzing). Use saturating_sub. Denies
the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:24:14 -04:00

377 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes.
//!
//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic
//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x`
//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease
//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and
//! value change `Δv`,
//!
//! ```text
//! o.x = influence_out o.y = speed_out · influence_out · d / Δv
//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv
//! ```
//!
//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the
//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys
//! set `h: 1`.
use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value};
use effectcraft_project::{Expression, Property};
use serde_json::{Value as Json, json};
use crate::Timebase;
/// Export state shared by the whole document.
pub(crate) struct Ex {
pub tb: Timebase,
pub include_expressions: bool,
pub warnings: Vec<String>,
}
impl Ex {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
}
/// Import state.
pub(crate) struct Im {
pub tb: Timebase,
pub warnings: Vec<String>,
}
impl Im {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
}
fn secs(t: effectcraft_time::Tick) -> f64 {
t.seconds()
}
fn r6(v: f64) -> f64 {
if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }
}
/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array).
fn wrap(j: Json) -> Json {
match j {
Json::Array(_) => j,
other => json!([other]),
}
}
fn is_spatial(p: &Property) -> bool {
p.spatial && matches!(p.value, Value::Vec2(_) | Value::Vec3(_))
}
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
/// `label` names the property in warnings.
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
if ex.include_expressions {
out["x"] = json!(e.text);
} else {
ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)"));
}
}
out
}
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
let keys = &p.keys;
let spatial = is_spatial(p);
let mut out = Vec::with_capacity(keys.len());
for (i, k) in keys.iter().enumerate() {
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
if i + 1 == keys.len() {
if k.out_interp == Interp::Hold || p.hold_only {
o["h"] = json!(1);
}
out.push(o);
continue;
}
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
o["h"] = json!(1);
out.push(o);
continue;
}
let (ox, oy, ix, iy) = segment_ease(keys, i, spatial);
o["o"] = json!({"x": ox, "y": oy});
o["i"] = json!({"x": ix, "y": iy});
if spatial {
let (_, to) = kf::spatial_tangents(keys, i);
let (ti, _) = kf::spatial_tangents(keys, i + 1);
let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
}
out.push(o);
}
out
}
/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension.
fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let (a, b) = (&keys[i], &keys[i + 1]);
let dur = (secs(b.time) - secs(a.time)).max(1e-12);
let ca = a.value.components();
let cb = b.value.components();
let numeric = !ca.is_empty() && ca.len() == cb.len();
let dims = if spatial || !numeric { 1 } else { ca.len() };
let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]);
for d in 0..dims {
let (dv, eo, ei) = if spatial {
let len = kf::spatial_segment_length(keys, i);
(len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
} else if numeric {
(cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
} else {
// Paths, gradients: overall progress 0 → 1.
let side = |k: &Keyframe, out: bool| {
let interp = if out { k.out_interp } else { k.in_interp };
let list = if out { &k.out_ease } else { &k.in_ease };
if interp == Interp::Bezier { list.first().copied().unwrap_or_default() } else { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } }
};
(1.0, Some(side(a, true)), Some(side(b, false)))
};
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
ox.push(r6(i0));
oy.push(r6(y0));
ix.push(r6(1.0 - i1));
iy.push(r6(y1));
}
(ox, oy, ix, iy)
}
// ---------------------------------------------------------------- import
/// Whether a Lottie property value is keyframed.
pub(crate) fn is_animated(j: &Json) -> bool {
if j.get("a").and_then(Json::as_i64) == Some(1) {
return true;
}
j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some())
}
/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array).
fn comp(j: &Json, field: &str, d: usize) -> Option<f64> {
match j.get(field)? {
Json::Number(n) => n.as_f64(),
Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64),
_ => None,
}
}
fn vec3_of(j: Option<&Json>) -> [f64; 3] {
let a = j.and_then(Json::as_array);
let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0);
[g(0), g(1), g(2)]
}
/// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie
/// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of
/// the property's type.
pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option<Value>) {
if !j.is_object() {
if let Some(v) = conv(j) {
p.value = v;
}
return;
}
if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) {
p.expr = Some(Expression { text: x.to_string(), enabled: true });
}
let Some(k) = j.get("k") else { return };
if !is_animated(j) {
if let Some(v) = conv(k) {
p.value = v;
}
return;
}
let Some(list) = k.as_array() else { return };
let spatial = is_spatial(p);
let mut keys: Vec<Keyframe> = Vec::with_capacity(list.len());
let mut kept: Vec<&Json> = Vec::with_capacity(list.len());
let mut prev_end: Option<&Json> = None;
for kj in list {
let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0);
let sv = kj.get("s").or(prev_end);
prev_end = kj.get("e");
let Some(v) = sv.and_then(conv) else { continue };
let mut key = Keyframe::new(im.tb.tick(t), v);
key.spatial_auto = false;
if p.hold_only || !key.value.interpolates() {
key = key.hold();
}
keys.push(key);
kept.push(kj);
}
if keys.is_empty() {
return;
}
let n = keys.len();
for i in 0..n.saturating_sub(1) {
let Some(kj) = kept.get(i) else { break };
if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only {
keys[i].out_interp = Interp::Hold;
keys[i + 1].in_interp = Interp::Hold;
continue;
}
if spatial {
let to = vec3_of(kj.get("to"));
let ti = vec3_of(kj.get("ti"));
keys[i].spatial_out = to;
keys[i + 1].spatial_in = ti;
}
}
// Eases need the spatial tangents (arc length), so they come second.
for i in 0..n.saturating_sub(1) {
let Some(kj) = kept.get(i) else { break };
if keys[i].out_interp == Interp::Hold {
continue;
}
let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else {
keys[i].out_interp = Interp::Linear;
keys[i + 1].in_interp = Interp::Linear;
continue;
};
let dur = (secs(keys[i + 1].time) - secs(keys[i].time)).max(1e-12);
let ca = keys[i].value.components();
let cb = keys[i + 1].value.components();
let numeric = !ca.is_empty() && ca.len() == cb.len();
let dims = if spatial || !numeric { 1 } else { ca.len() };
let mut linear = true;
let mut outs = vec![];
let mut ins = vec![];
for d in 0..dims {
let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0);
let oy = comp(o, "y", d).unwrap_or(ox);
let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0);
let iy = comp(ii, "y", d).unwrap_or(ix);
if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 {
linear = false;
}
let dv = if spatial {
kf::spatial_segment_length(&keys, i)
} else if numeric {
cb[d] - ca[d]
} else {
1.0
};
let inf_o = ox.max(0.001);
let inf_i = (1.0 - ix).max(0.001);
outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o });
ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i });
}
if linear {
keys[i].out_interp = Interp::Linear;
keys[i + 1].in_interp = Interp::Linear;
} else {
keys[i].out_interp = Interp::Bezier;
keys[i].out_ease = outs;
keys[i + 1].in_interp = Interp::Bezier;
keys[i + 1].in_ease = ins;
}
}
p.value = keys[0].value.clone();
p.keys = keys;
}
// ---------------------------------------------------------------- value converters
pub(crate) fn num(v: &Value) -> Json {
json!(r6(v.as_f64()))
}
pub(crate) fn arr(v: &Value) -> Json {
match v {
Value::Scalar(x) => json!([r6(*x)]),
_ => json!(v.components().iter().map(|x| r6(*x)).collect::<Vec<_>>()),
}
}
/// Vec2 (first two components of any vector).
pub(crate) fn arr2(v: &Value) -> Json {
let c = v.components();
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0))])
}
/// Vec3 (padded with `z`).
pub(crate) fn arr3(v: &Value) -> Json {
let c = v.components();
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))])
}
/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`).
pub(crate) fn nums(j: &Json) -> Option<Vec<f64>> {
match j {
Json::Number(n) => Some(vec![n.as_f64()?]),
Json::Array(a) => {
// `[[x, y]]`-style wrapping.
if a.len() == 1
&& let Some(inner @ Json::Array(_)) = a.first()
{
return nums(inner);
}
a.iter().map(Json::as_f64).collect()
}
_ => None,
}
}
pub(crate) fn to_scalar(j: &Json) -> Option<Value> {
nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
}
pub(crate) fn to_vec2(j: &Json) -> Option<Value> {
let v = nums(j)?;
Some(Value::Vec2([*v.first()?, *v.get(1).unwrap_or(&0.0)]))
}
/// Vec3 with a default third component.
pub(crate) fn to_vec3(j: &Json, z: f64) -> Option<Value> {
let v = nums(j)?;
Some(Value::Vec3([*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&z)]))
}
pub(crate) fn to_color(j: &Json) -> Option<Value> {
let v = nums(j)?;
let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)];
// Some files use 0–255.
if c[..3].iter().any(|x| *x > 1.0) {
for x in &mut c[..3] {
*x /= 255.0;
}
}
Some(Value::Color(c))
}
pub(crate) fn to_bool(j: &Json) -> Option<Value> {
match j {
Json::Bool(b) => Some(Value::Bool(*b)),
_ => to_scalar(j).map(|v| Value::Bool(v.as_f64() != 0.0)),
}
}
/// Lottie 1-based enum → 0-based popup index.
pub(crate) fn to_enum1(j: &Json) -> Option<Value> {
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64).saturating_sub(1).max(0) as u32))
}