diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 5b6dabf50..40a56a7eb 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -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) { 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) { 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); } } diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 57ef6427a..1e1430e12 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -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::(); 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 = BTreeMap::new(); - // (layer id, parent ind, matte: (kind, explicit tp ind), json position) - let mut pending: Vec<(LayerId, Option, Option<(MatteKind, Option)>, 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, Option<(MatteKind, Option)>, usize)> = vec![]; let mut pos_ind: Vec> = 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; } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fab8d3646..6cd1f09cc 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -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 { +pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator) -> Vec { 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 { - let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default(); - let inner: Vec = 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") diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index b941b4f61..8656852de 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -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 = (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 = (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 = (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() { diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..4e50b461d 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -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")