diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 90519a8db..53b1ba71c 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -316,7 +316,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 cbe89d58c..d26b3642d 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -533,6 +533,31 @@ fn embedded_glyphs_are_reported_when_dropped() { assert_eq!(warnings(&blank), ["embedded glyphs (`chars`) are not imported; text uses the installed fonts"]); } +#[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();