From ba37283aa4266af6555855fb82ef1dc538c90e85 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:18 +0700 Subject: [PATCH 1/2] fix(lottie): serialize Offset Paths miter limits as animatable values --- crates/lottie/src/shapes.rs | 6 ++--- crates/lottie/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index d7e3cc08..1283a249 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -308,7 +308,7 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve "op" => { o.put("a", "amount", &anim::num); o.raw("lj", json!(o.e("join") + 1)); - o.raw("ml", json!(o.f("miter"))); + o.put("ml", "miter", &anim::num); if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 { o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)")); } @@ -543,9 +543,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec set(im, &mut g, "amount", j.get("a"), &anim::to_scalar), "tw" => { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index a677def1..b4638cb8 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -358,6 +358,51 @@ fn path_operators_roundtrip() { compare_renders(&p, cid, &q, nc, 2.0 / 255.0); } +#[test] +fn offset_paths_miter_uses_animatable_lottie_value() { + let (mut p, cid) = setup(); + let l = shape_layer(&mut p, cid, |ids| { + let mut animated = build::shape_simple_op(ids, "offset").unwrap(); + animated.get_mut("miter").unwrap().keys = vec![ + Keyframe::new(s(0.0), Value::Scalar(5.0)), + Keyframe::new(s(1.0), Value::Scalar(9.0)), + ]; + let mut constant = build::shape_simple_op(ids, "offset").unwrap(); + constant.get_mut("miter").unwrap().value = Value::Scalar(6.0); + let mut stroke = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0); + stroke.get_mut("miter").unwrap().value = Value::Scalar(7.0); + vec![animated, constant, stroke] + }); + push(&mut p, cid, l); + + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + let shapes = &json["layers"][0]["shapes"]; + assert_eq!(shapes[0]["ty"], "op"); + assert_eq!(shapes[0]["ml"]["a"], json!(1)); + assert_eq!(shapes[0]["ml"]["k"][0]["s"], json!([5.0])); + assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0])); + assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0})); + assert_eq!(shapes[2]["ty"], "st"); + assert_eq!(shapes[2]["ml"], json!(7.0)); + assert!(compare_comps(&p, cid, &q, nc) > 10); + let restored = &q.comp(nc).unwrap().layers[0]; + assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2); + + // Previously exported Offset Paths have a scalar ml; they remain importable. + let mut old = json; + old["layers"][0]["shapes"][1]["ml"] = json!(6.0); + let mut legacy = Project::default(); + let bytes = serde_json::to_vec(&old).unwrap(); + let imported = import(&mut legacy, &bytes, "Legacy", "", &mut |_| None).unwrap(); + let constant = &legacy.comp(imported.comp).unwrap().layers[0].props; + let second = constant.sub("contents").unwrap().children.iter().filter_map(|n| match n { + Node::Group(g) if g.match_id == "offset" => Some(g), + _ => None, + }).nth(1).unwrap(); + assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0)); +} + // ---------------------------------------------------------------- layers #[test] From 2cf08555a6555367b42409b7416ee9918a38e98a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:25 +0700 Subject: [PATCH 2/2] Lottie: rustfmt the Offset Paths test Co-Authored-By: Claude Opus 5.5 --- crates/lottie/src/tests.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index b4638cb8..19aac0f9 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -363,10 +363,7 @@ fn offset_paths_miter_uses_animatable_lottie_value() { let (mut p, cid) = setup(); let l = shape_layer(&mut p, cid, |ids| { let mut animated = build::shape_simple_op(ids, "offset").unwrap(); - animated.get_mut("miter").unwrap().keys = vec![ - Keyframe::new(s(0.0), Value::Scalar(5.0)), - Keyframe::new(s(1.0), Value::Scalar(9.0)), - ]; + animated.get_mut("miter").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(5.0)), Keyframe::new(s(1.0), Value::Scalar(9.0))]; let mut constant = build::shape_simple_op(ids, "offset").unwrap(); constant.get_mut("miter").unwrap().value = Value::Scalar(6.0); let mut stroke = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0); @@ -396,10 +393,17 @@ fn offset_paths_miter_uses_animatable_lottie_value() { let bytes = serde_json::to_vec(&old).unwrap(); let imported = import(&mut legacy, &bytes, "Legacy", "", &mut |_| None).unwrap(); let constant = &legacy.comp(imported.comp).unwrap().layers[0].props; - let second = constant.sub("contents").unwrap().children.iter().filter_map(|n| match n { - Node::Group(g) if g.match_id == "offset" => Some(g), - _ => None, - }).nth(1).unwrap(); + let second = constant + .sub("contents") + .unwrap() + .children + .iter() + .filter_map(|n| match n { + Node::Group(g) if g.match_id == "offset" => Some(g), + _ => None, + }) + .nth(1) + .unwrap(); assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0)); }