fix(lottie): import indexes precomp sizes and layers, borrows shape groups, bounds font names and warnings (#3007)

This commit is contained in:
Dylan M. Taylor
2026-11-11 14:36:44 -05:00
committed by GitHub
parent 5d2524c00b
commit e18f48fbf4
5 changed files with 91 additions and 27 deletions
+7 -2
View File
@@ -21,6 +21,9 @@ use serde_json::{Value as Json, json};
use crate::Timebase;
/// Most distinct warnings an import or export reports.
const MAX_WARNINGS: usize = 200;
/// Export state shared by the whole document.
pub(crate) struct Ex {
pub tb: Timebase,
@@ -31,7 +34,8 @@ pub(crate) struct Ex {
impl Ex {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
// Each warning is looked for among the earlier ones: past a screenful, more add nothing.
if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
@@ -46,7 +50,8 @@ pub(crate) struct Im {
impl Im {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
// Each warning is looked for among the earlier ones: past a screenful, more add nothing.
if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
+30 -20
View File
@@ -1,6 +1,6 @@
//! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]).
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use effectcraft_color::Label;
@@ -17,6 +17,9 @@ use crate::anim::{self, Im, import_prop};
use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from};
use crate::{LottieError, Timebase, shapes, text};
/// Longest font family or style name kept (each text key using the font holds a copy).
const MAX_FONT_NAME_CHARS: usize = 256;
/// An embedded image asset handed to the caller to store (the returned path becomes the
/// footage item's file).
#[derive(Clone, Debug)]
@@ -129,9 +132,12 @@ pub fn import(
Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() };
if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) {
for fo in list {
// Every text key referring to the font copies its family and style: keep them to
// a font name's length.
let short = |v: &str| v.chars().take(MAX_FONT_NAME_CHARS).collect::<String>();
let n = s(fo, "fName").unwrap_or("").to_string();
let fam = s(fo, "fFamily").unwrap_or(&n).to_string();
let st = s(fo, "fStyle").unwrap_or("Regular").to_string();
let fam = short(s(fo, "fFamily").unwrap_or(&n));
let st = short(s(fo, "fStyle").unwrap_or("Regular"));
cx.fonts.push((n, fam, st));
}
}
@@ -150,11 +156,12 @@ pub fn import(
let duration = tb.tick(op);
// Pass 1: asset items (precomps empty for now, images stored).
let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default();
let layer_sizes = precomp_layer_sizes(&doc, &assets);
for a in &assets {
let Some(id) = s(a, "id") else { continue };
if a.get("layers").is_some() {
let folder = cx.folder(project);
let layer = precomp_layer_size(&doc, &assets, id);
let layer = layer_sizes.get(id).copied();
let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w);
let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h);
let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
@@ -255,17 +262,20 @@ pub fn import(
Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
}
/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`.
/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect.
fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> {
/// By asset id, the `w`/`h` of the first precomp layer, in the main comp or in any asset, that
/// shows it. lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its
/// clipping rect. One pass over all layers, not one per asset.
fn precomp_layer_sizes<'a>(doc: &'a Json, assets: &'a [Json]) -> HashMap<&'a str, (u32, u32)> {
let mut out = HashMap::new();
let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers")));
lists.filter_map(Json::as_array).flatten().find_map(|l| {
if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) {
return None;
for l in lists.filter_map(Json::as_array).flatten() {
if l.get("ty").and_then(Json::as_u64) != Some(0) {
continue;
}
let (lw, lh) = f(l, "w").zip(f(l, "h"))?;
Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32))
})
let (Some(id), Some((lw, lh))) = (s(l, "refId"), f(l, "w").zip(f(l, "h"))) else { continue };
out.entry(id).or_insert((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32));
}
out
}
fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
@@ -411,8 +421,8 @@ fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> O
fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) {
let tb = cx.im.tb;
let mut by_ind: BTreeMap<i64, LayerId> = BTreeMap::new();
// (layer id, parent ind, matte: (kind, explicit tp ind), json position)
let mut pending: Vec<(LayerId, Option<i64>, Option<(MatteKind, Option<i64>)>, usize)> = vec![];
// (layer id, its index in `comp.layers`, parent ind, matte: (kind, explicit tp ind), json position)
let mut pending: Vec<(LayerId, usize, Option<i64>, Option<(MatteKind, Option<i64>)>, usize)> = vec![];
let mut pos_ind: Vec<Option<i64>> = vec![];
for (ji, j) in layers.iter().enumerate() {
let ind = j.get("ind").and_then(Json::as_i64);
@@ -463,8 +473,8 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
let mut ids = Ids(&mut next);
match &source {
LayerSource::Shape => {
let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default();
let groups = shapes::import_items(&mut cx.im, &mut ids, &items);
let mut items = j.get("shapes").and_then(Json::as_array).into_iter().flatten();
let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Node::Group));
}
@@ -507,10 +517,10 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
_ => None,
}
.map(|k| (k, j.get("tp").and_then(Json::as_i64)));
pending.push((l.id, parent, matte, ji));
pending.push((l.id, comp.layers.len(), parent, matte, ji));
comp.layers.push(l);
}
for (id, parent, matte, ji) in pending {
for (id, at, parent, matte, ji) in pending {
let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id);
let matte_id = matte.and_then(|(k, tp)| {
let target = match tp {
@@ -520,7 +530,7 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
};
target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k })
});
if let Some(l) = comp.layer_mut(id) {
if let Some(l) = comp.layers.get_mut(at) {
l.parent = parent_id;
l.track_matte = matte_id;
}
+5 -4
View File
@@ -436,7 +436,7 @@ fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) {
}
/// Import Lottie shape items into shape contents groups.
pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<PropGroup> {
pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator<Item = &Json>) -> Vec<PropGroup> {
let mut out = vec![];
for j in items {
let ty = j.get("ty").and_then(Json::as_str).unwrap_or("");
@@ -447,9 +447,10 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
};
let mut g = match ty {
"gr" => {
let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default();
let inner: Vec<Json> = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect();
let children = import_items(im, ids, &inner);
// Borrowed, not cloned: each nesting level copying its subtree costs memory
// quadratic in the depth.
let list = j.get("it").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default();
let children = import_items(im, ids, &mut list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")));
let mut g = build::shape_group(ids, "Group 1", children);
if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr"))
&& let Some(t) = g.sub_mut("transform")
+47
View File
@@ -997,6 +997,53 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() {
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
}
#[test]
fn many_layers_and_precomps_import_with_their_parents_and_sizes() {
// 5 000 null layers each parented to the previous, and 2 000 empty precomp assets: the
// relationship and size passes index the layers instead of searching them per layer. (Each
// has its own name: `Comp::unique_layer_name` numbering one repeated name is a separate cost.)
let n = 5_000;
let layers: Vec<Json> = (1..=n).map(|i| json!({"ty": 3, "nm": format!("N{i}"), "ind": i, "parent": i - 1, "ip": 0, "op": 30, "st": 0, "ks": {}})).collect();
let mut assets: Vec<Json> = (0..2_000).map(|i| json!({"id": format!("c{i}"), "layers": []})).collect();
assets.push(json!({"id": "last", "layers": []}));
let mut layers = layers;
layers.push(json!({"ty": 0, "ind": n + 1, "refId": "last", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}}));
let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": layers});
let mut q = Project::default();
let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
let c = q.comp(nc).unwrap();
assert_eq!(c.layers.len(), n as usize + 1);
assert_eq!(c.layers.get(0).unwrap().parent, None);
assert_eq!(c.layers.get(1).unwrap().parent, Some(c.layers.get(0).unwrap().id));
let LayerSource::Comp { item } = c.layers.get(n as usize).unwrap().source else { panic!("not a precomp") };
let pc = q.comp(item).unwrap();
assert_eq!((pc.width, pc.height), (50, 40));
}
#[test]
fn deeply_nested_shape_groups_and_long_font_names_import() {
let mut g = json!({"ty": "rc", "s": {"a": 0, "k": [10, 10]}, "p": {"a": 0, "k": [0, 0]}, "r": {"a": 0, "k": 0}});
// 50 levels: each is two levels of JSON, within serde_json's recursion limit of 128.
for _ in 0..50 {
g = json!({"ty": "gr", "it": [g, {"ty": "tr", "o": {"a": 0, "k": 100}}]});
}
let family = "F".repeat(100_000);
let keys: Vec<Json> = (0..50).map(|i| json!({"t": i, "s": {"t": "x", "f": "A", "s": 10}})).collect();
let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 60,
"fonts": {"list": [{"fName": "A", "fFamily": family, "fStyle": "Regular"}]},
"layers": [
{"ty": 4, "ind": 1, "ip": 0, "op": 60, "st": 0, "ks": {}, "shapes": [g]},
{"ty": 5, "ind": 2, "ip": 0, "op": 60, "st": 0, "ks": {}, "t": {"d": {"k": keys}}}
]});
let mut q = Project::default();
let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
let c = q.comp(nc).unwrap();
let st = c.layers.get(1).unwrap().props.prop("text/sourceText").unwrap();
assert_eq!(st.keys.len(), 50);
let Value::Text(d) = &st.keys[0].value else { panic!("not text") };
assert_eq!(d.font.len(), 256);
}
/// A footage item called a still whose file is not an image is not embedded.
#[test]
fn only_image_bytes_are_embedded() {
+2 -1
View File
@@ -310,7 +310,8 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro
st.value = Value::Text(Box::new(first.clone()));
}
if docs.len() > 1 {
st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect();
// Moved into the keys, not cloned: the documents are not kept twice.
st.keys = docs.into_iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(f), Value::Text(Box::new(d))).hold()).collect();
}
}
if let Some(m) = t.get("m")