From 7d00b5322d41b921a45d1b7691060525c0498780 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:07:55 -0300 Subject: [PATCH] Lottie: read the closed flag older Bodymovin files keep outside the path Bodymovin 3.x and early 4.x write `cl` on a mask and `closed` on an `sh` item, with no `c` in the path, so the import brought these masks and paths in open. An open mask doesn't cut its layer, and masked solids filled the frame. The path's own `c` still wins. Fixes #2837. --- crates/lottie/src/import.rs | 3 ++- crates/lottie/src/shapes.rs | 9 ++++++--- crates/lottie/src/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 6d7cc1f5a..7a374ae9b 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -305,7 +305,8 @@ fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) { import_prop(im, j, p, conv); } }; - set("pt", "path", &shapes::to_path); + let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false); + set("pt", "path", &|v| shapes::to_path(v, closed)); set("o", "opacity", &anim::to_scalar); set("x", "expansion", &anim::to_scalar); set("f", "feather", &anim::to_vec2); diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 5b9d29970..fab8d3646 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -27,7 +27,9 @@ pub(crate) fn path_value(v: &Value) -> Json { } } -pub(crate) fn to_path(j: &Json) -> Option { +/// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the +/// flag outside the path, as `cl` on a mask and `closed` on an `sh` item. +pub(crate) fn to_path(j: &Json, closed: bool) -> Option { let o = match j { Json::Array(a) => a.first()?, o => o, @@ -48,7 +50,7 @@ pub(crate) fn to_path(j: &Json) -> Option { in_tangents: fit(pts("i")), out_tangents: fit(pts("o")), vertices, - closed: o.get("c").and_then(Json::as_bool).unwrap_or(false), + closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed), feather: Vec::new(), })) } @@ -493,7 +495,8 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { let mut g = build::shape_path(ids, ShapePath::default()); dir(&mut g); - set(im, &mut g, "path", j.get("ks"), &to_path); + let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false); + set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed)); g } "fl" => { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 83c81e242..62bec834a 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,31 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn legacy_closed_flags_close_masks_and_paths() { + // Bodymovin 3.x and early 4.x put the closed flag on the mask (`cl`) and on the `sh` item + // (`closed`), with no `c` in the path. An open mask would leave the solid whole. + let square = json!({"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[5, 5], [15, 5], [15, 15], [5, 15]]}); + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [ + {"ty": 4, "ind": 1, "ip": 0, "op": 30, "st": 0, "ks": {}, + "shapes": [{"ty": "sh", "closed": true, "ks": {"k": [{"t": 0, "s": [square], "e": [square]}, {"t": 10}]}}]}, + {"ty": 1, "ind": 2, "sc": "#ff0000", "sw": 20, "sh": 20, "ip": 0, "op": 30, "st": 0, "ks": {}, + "masksProperties": [{"mode": "a", "cl": true, "pt": {"k": square}}]}]}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let comp = q.comp(nc).unwrap(); + let closed = |v: &Value| matches!(v, Value::Path(sp) if sp.closed); + let shape = comp.layers[0].props.sub("contents").unwrap().groups().next().unwrap(); + let keys = &shape.get("path").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!(keys.iter().all(|k| closed(&k.value))); + let mask = comp.layers[1].masks().unwrap().groups().next().unwrap(); + assert!(closed(&mask.get("path").unwrap().value)); + let img = render_frame(&q, nc, s(0.0), 1.0); + assert_eq!(img.data[0][3], 0.0, "the mask cuts the corner away"); + assert!(img.data[10 * 20 + 10][3] > 0.99); +} + #[test] fn precomp_with_time_remap() { let (mut p, cid) = setup();