mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 23:32:17 +00:00
fix(lottie): import indexes precomp sizes and layers, borrows shape groups, bounds font names and warnings (#3007)
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user