diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index fb8f22e8..10635b69 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -17,6 +17,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 |
## Screenshots
diff --git a/Cargo.lock b/Cargo.lock
index 8519ae6b..abc7c38f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1191,6 +1191,7 @@ dependencies = [
"effectcraft-effects",
"effectcraft-geom",
"effectcraft-keyframe",
+ "effectcraft-lottie",
"effectcraft-path",
"effectcraft-project",
"effectcraft-raster",
@@ -1275,6 +1276,23 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "effectcraft-lottie"
+version = "0.1.0"
+dependencies = [
+ "effectcraft-color",
+ "effectcraft-effects",
+ "effectcraft-keyframe",
+ "effectcraft-path",
+ "effectcraft-project",
+ "effectcraft-render",
+ "effectcraft-text",
+ "effectcraft-time",
+ "kurbo",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "effectcraft-media"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index a22b766c..241a3c98 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ effectcraft-render = { path = "crates/render" }
effectcraft-media = { path = "crates/media" }
effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" }
+effectcraft-lottie = { path = "crates/lottie" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" }
effectcraft-ui-egui = { path = "crates/ui-egui" }
diff --git a/README.md b/README.md
index 47d5e5af..576c8093 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,11 @@ A Render Queue like After Effects', with Render Settings and Output Modules: **H
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
FilmCraft's pure-Rust code; there is no FFmpeg inside.
+**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,
+solid, image, text and null layers, eased and spatial keyframes, masks, track mattes, blend
+modes, time remapping, optionally expressions) as `.json` or `.lottie`, and lists anything Lottie
+cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
+
@@ -154,7 +159,8 @@ them:
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
- **Command line:** one-shot calls with JSON output, for example
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save`
- or `effectcraft-cli render --comp Main --out main.mp4`.
+ or `effectcraft-cli render --comp Main --out main.mp4`, or
+ `effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json"}' main.ecproj`.
- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
and click any widget by its automation id, and take screenshots.
@@ -180,7 +186,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
-paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build
+paint and roto tools, preferences and a shortcut editor, and GPU compositing. The web build
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.
diff --git a/ROADMAP.md b/ROADMAP.md
index dc12d39f..e9061b77 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -17,7 +17,7 @@ once.
| M8 | Expressions with the After Effects object model | Done |
| M9 | Text and effects: text animators, layer styles, 259 effects incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
-| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
+| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 815b2ab9..eef636bf 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -20,6 +20,7 @@ effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
+effectcraft-lottie = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
diff --git a/crates/engine/src/commands/lottie.rs b/crates/engine/src/commands/lottie.rs
new file mode 100644
index 00000000..03a99b99
--- /dev/null
+++ b/crates/engine/src/commands/lottie.rs
@@ -0,0 +1,67 @@
+//! File ▸ Export ▸ Lottie JSON… and File ▸ Import ▸ Lottie… (`effectcraft-lottie`).
+
+use effectcraft_lottie::{ExportOptions, ImportedImage};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, str_p};
+use crate::{EngineError, Result, Session, cmd};
+
+fn export_lottie(s: &mut Session, p: &Value) -> Result {
+ let path = str_p(p, "path").ok_or_else(|| bad("file.exportLottie", "missing `path`"))?.to_string();
+ let cid = comp_id(s, p)?;
+ let opts = ExportOptions { include_expressions: b_p(p, "includeExpressions").unwrap_or(false), text_as_shapes: b_p(p, "textAsShapes").unwrap_or(false) };
+ let services = s.services.clone();
+ let read = move |f: &str| services.read_file(f).ok();
+ let res = effectcraft_lottie::export_comp(&s.project, cid, &opts, &read).map_err(|e| EngineError::Other(e.to_string()))?;
+ let dot = path.to_ascii_lowercase().ends_with(".lottie");
+ let bytes = if dot {
+ let stem = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ effectcraft_lottie::to_dotlottie(&res, &stem)
+ } else {
+ res.to_string_compact().into_bytes()
+ };
+ s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
+ let n = res.warnings.len();
+ s.toast(if n == 0 { format!("Exported Lottie to {path}") } else { format!("Exported Lottie to {path} ({n} warning{})", if n == 1 { "" } else { "s" }) });
+ Ok(json!({"path": path, "bytes": bytes.len(), "warnings": res.warnings}))
+}
+
+fn import_lottie(s: &mut Session, p: &Value) -> Result {
+ let path = str_p(p, "path").ok_or_else(|| bad("file.importLottie", "missing `path`"))?.to_string();
+ let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
+ let fp = std::path::Path::new(&path);
+ let dir = fp.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
+ let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Lottie".into());
+ let services = s.services.clone();
+ // Embedded images are written next to the Lottie file (`_.`).
+ let mut store = |img: &ImportedImage| -> Option {
+ let safe: String = img.id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
+ let file = std::path::Path::new(&dir).join(format!("{stem}_{safe}.{}", img.ext)).to_string_lossy().to_string();
+ services.write_file(&file, &img.bytes).ok().map(|_| file)
+ };
+ let res = s.edit("Import Lottie", None, |proj, st| {
+ let r = effectcraft_lottie::import(proj, &bytes, &stem, &dir, &mut store).map_err(|e| EngineError::Other(e.to_string()))?;
+ st.project_selection = vec![r.comp];
+ Ok(r)
+ })?;
+ s.open_comp(res.comp);
+ if !res.warnings.is_empty() {
+ s.toast(format!("Imported {path} ({} warnings)", res.warnings.len()));
+ }
+ Ok(json!({"comp": res.comp.0, "items": res.items.iter().map(|i| i.0).collect::>(), "warnings": res.warnings}))
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "file.exportLottie",
+ "Lottie JSON...",
+ ["File", "Export"],
+ None,
+ "{comp?, path (.json or .lottie), includeExpressions?: bool, textAsShapes?: bool}",
+ has_comp,
+ export_lottie
+ ),
+ cmd!("file.importLottie", "Lottie...", ["File", "Import"], None, "{path (.json or .lottie)}", always, import_lottie),
+ ]
+}
diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs
index 6301e440..52bec4e2 100644
--- a/crates/engine/src/commands/mod.rs
+++ b/crates/engine/src/commands/mod.rs
@@ -15,6 +15,7 @@ mod layer;
mod layer_menu;
mod layer_time;
mod link;
+mod lottie;
mod mask;
mod prop;
mod query;
@@ -100,6 +101,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs());
v.extend(view::specs());
v.extend(file_more::specs());
+ v.extend(lottie::specs());
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(stubs::specs());
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 5195fe72..03fc2f6b 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -454,6 +454,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
+mod tests_lottie;
+#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_styles;
diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs
index 8965da25..783896ef 100644
--- a/crates/engine/src/menus.rs
+++ b/crates/engine/src/menus.rs
@@ -307,8 +307,10 @@ File
Multiple Files... | file.importMultiple
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
+ Lottie... | file.importLottie
Export
Add to Render Queue | renderQueue.add
+ Lottie JSON... | file.exportLottie
---
Find | app.find
---
diff --git a/crates/engine/src/tests_lottie.rs b/crates/engine/src/tests_lottie.rs
new file mode 100644
index 00000000..4fe99f95
--- /dev/null
+++ b/crates/engine/src/tests_lottie.rs
@@ -0,0 +1,60 @@
+//! File ▸ Export ▸ Lottie JSON… / File ▸ Import ▸ Lottie… through the command registry.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+
+fn tmp(name: &str) -> String {
+ let d = std::env::temp_dir().join(format!("effectcraft-lottie-tests-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ d.join(name).to_string_lossy().to_string()
+}
+
+fn session() -> Session {
+ let mut s = Session::default();
+ s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap();
+ s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 100, "height": 60})).unwrap();
+ s.execute("layer.newShape", json!({})).ok();
+ s
+}
+
+#[test]
+fn export_and_import_lottie_commands() {
+ let mut s = session();
+ let n_layers = s.active_comp().unwrap().layers.len();
+ s.execute("prop.addKey", json!({"layer": "#1", "path": "transform/opacity", "time": 0.0, "value": 0.0})).unwrap();
+ s.execute("prop.addKey", json!({"layer": "#1", "path": "transform/opacity", "time": 1.0, "value": 100.0})).unwrap();
+ let path = tmp("main.json");
+ let r = s.execute_checked("file.exportLottie", json!({"path": path, "includeExpressions": true})).unwrap();
+ assert!(r["bytes"].as_u64().unwrap() > 100);
+ let doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+ assert_eq!(doc["w"], json!(320));
+ assert_eq!(doc["layers"].as_array().unwrap().len(), n_layers);
+ // Import as a new comp (undoable).
+ let before = s.project.items.len();
+ let r = s.execute_checked("file.importLottie", json!({"path": path})).unwrap();
+ let cid = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
+ assert_eq!(s.state.active_comp, Some(cid));
+ let c = s.project.comp(cid).unwrap();
+ assert_eq!(c.layers.len(), n_layers);
+ let op = c.layers[0].props.prop("transform/opacity").unwrap();
+ assert_eq!(op.keys.len(), 2);
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(s.project.items.len(), before);
+ // dotLottie.
+ let dot = tmp("main.lottie");
+ s.execute("file.exportLottie", json!({"path": dot})).unwrap();
+ assert!(std::fs::read(&dot).unwrap().starts_with(b"PK"));
+ let r = s.execute("file.importLottie", json!({"path": dot})).unwrap();
+ assert!(r["comp"].as_u64().is_some());
+ // Unknown parameters are rejected for agents.
+ assert!(s.execute_checked("file.exportLottie", json!({"path": path, "bogus": 1})).is_err());
+}
+
+#[test]
+fn lottie_menu_entries_exist() {
+ let entries = crate::menus::entries();
+ let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
+ assert_eq!(find("file.exportLottie"), Some(("File > Export".into(), "Lottie JSON...".into())));
+ assert_eq!(find("file.importLottie"), Some(("File > Import".into(), "Lottie...".into())));
+}
diff --git a/crates/lottie/Cargo.toml b/crates/lottie/Cargo.toml
new file mode 100644
index 00000000..ec71de40
--- /dev/null
+++ b/crates/lottie/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "effectcraft-lottie"
+description = "Lottie JSON import and export: compositions, precomps, shape/solid/null/image/text layers, keyframes with temporal and spatial easing, masks, track mattes and a few effects"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+effectcraft-time = { workspace = true }
+effectcraft-color = { workspace = true }
+effectcraft-keyframe = { workspace = true }
+effectcraft-project = { workspace = true }
+effectcraft-path = { workspace = true }
+effectcraft-text = { workspace = true }
+effectcraft-effects = { workspace = true }
+kurbo = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+effectcraft-render = { workspace = true }
diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs
new file mode 100644
index 00000000..3a0f662e
--- /dev/null
+++ b/crates/lottie/src/anim.rs
@@ -0,0 +1,376 @@
+//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes.
+//!
+//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic
+//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x`
+//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease
+//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and
+//! value change `Δv`,
+//!
+//! ```text
+//! o.x = influence_out o.y = speed_out · influence_out · d / Δv
+//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv
+//! ```
+//!
+//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the
+//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys
+//! set `h: 1`.
+
+use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value};
+use effectcraft_project::{Expression, Property};
+use serde_json::{Value as Json, json};
+
+use crate::Timebase;
+
+/// Export state shared by the whole document.
+pub(crate) struct Ex {
+ pub tb: Timebase,
+ pub include_expressions: bool,
+ pub warnings: Vec,
+}
+
+impl Ex {
+ pub fn warn(&mut self, w: impl Into) {
+ let w = w.into();
+ if !self.warnings.contains(&w) {
+ self.warnings.push(w);
+ }
+ }
+}
+
+/// Import state.
+pub(crate) struct Im {
+ pub tb: Timebase,
+ pub warnings: Vec,
+}
+
+impl Im {
+ pub fn warn(&mut self, w: impl Into) {
+ let w = w.into();
+ if !self.warnings.contains(&w) {
+ self.warnings.push(w);
+ }
+ }
+}
+
+fn secs(t: effectcraft_time::Tick) -> f64 {
+ t.seconds()
+}
+
+fn r6(v: f64) -> f64 {
+ if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }
+}
+
+/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array).
+fn wrap(j: Json) -> Json {
+ match j {
+ Json::Array(_) => j,
+ other => json!([other]),
+ }
+}
+
+fn is_spatial(p: &Property) -> bool {
+ p.spatial && matches!(p.value, Value::Vec2(_) | Value::Vec3(_))
+}
+
+/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
+/// `label` names the property in warnings.
+pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
+ let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
+ if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
+ if ex.include_expressions {
+ out["x"] = json!(e.text);
+ } else {
+ ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)"));
+ }
+ }
+ out
+}
+
+fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec {
+ let keys = &p.keys;
+ let spatial = is_spatial(p);
+ let mut out = Vec::with_capacity(keys.len());
+ for (i, k) in keys.iter().enumerate() {
+ let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
+ if i + 1 == keys.len() {
+ if k.out_interp == Interp::Hold || p.hold_only {
+ o["h"] = json!(1);
+ }
+ out.push(o);
+ continue;
+ }
+ if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
+ o["h"] = json!(1);
+ out.push(o);
+ continue;
+ }
+ let (ox, oy, ix, iy) = segment_ease(keys, i, spatial);
+ o["o"] = json!({"x": ox, "y": oy});
+ o["i"] = json!({"x": ix, "y": iy});
+ if spatial {
+ let (_, to) = kf::spatial_tangents(keys, i);
+ let (ti, _) = kf::spatial_tangents(keys, i + 1);
+ let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
+ o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::>());
+ o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::>());
+ }
+ out.push(o);
+ }
+ out
+}
+
+/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension.
+fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec, Vec, Vec, Vec) {
+ let (a, b) = (&keys[i], &keys[i + 1]);
+ let dur = (secs(b.time) - secs(a.time)).max(1e-12);
+ let ca = a.value.components();
+ let cb = b.value.components();
+ let numeric = !ca.is_empty() && ca.len() == cb.len();
+ let dims = if spatial || !numeric { 1 } else { ca.len() };
+ let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]);
+ for d in 0..dims {
+ let (dv, eo, ei) = if spatial {
+ let len = kf::spatial_segment_length(keys, i);
+ (len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
+ } else if numeric {
+ (cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
+ } else {
+ // Paths, gradients: overall progress 0 → 1.
+ let side = |k: &Keyframe, out: bool| {
+ let interp = if out { k.out_interp } else { k.in_interp };
+ let list = if out { &k.out_ease } else { &k.in_ease };
+ if interp == Interp::Bezier { list.first().copied().unwrap_or_default() } else { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } }
+ };
+ (1.0, Some(side(a, true)), Some(side(b, false)))
+ };
+ let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
+ let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
+ let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
+ if i0 + i1 > 1.0 {
+ let k = 1.0 / (i0 + i1);
+ i0 *= k;
+ i1 *= k;
+ }
+ let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
+ ox.push(r6(i0));
+ oy.push(r6(y0));
+ ix.push(r6(1.0 - i1));
+ iy.push(r6(y1));
+ }
+ (ox, oy, ix, iy)
+}
+
+// ---------------------------------------------------------------- import
+
+/// Whether a Lottie property value is keyframed.
+pub(crate) fn is_animated(j: &Json) -> bool {
+ if j.get("a").and_then(Json::as_i64) == Some(1) {
+ return true;
+ }
+ j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some())
+}
+
+/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array).
+fn comp(j: &Json, field: &str, d: usize) -> Option {
+ match j.get(field)? {
+ Json::Number(n) => n.as_f64(),
+ Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64),
+ _ => None,
+ }
+}
+
+fn vec3_of(j: Option<&Json>) -> [f64; 3] {
+ let a = j.and_then(Json::as_array);
+ let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0);
+ [g(0), g(1), g(2)]
+}
+
+/// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie
+/// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of
+/// the property's type.
+pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option) {
+ if !j.is_object() {
+ if let Some(v) = conv(j) {
+ p.value = v;
+ }
+ return;
+ }
+ if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) {
+ p.expr = Some(Expression { text: x.to_string(), enabled: true });
+ }
+ let Some(k) = j.get("k") else { return };
+ if !is_animated(j) {
+ if let Some(v) = conv(k) {
+ p.value = v;
+ }
+ return;
+ }
+ let Some(list) = k.as_array() else { return };
+ let spatial = is_spatial(p);
+ let mut keys: Vec = Vec::with_capacity(list.len());
+ let mut kept: Vec<&Json> = Vec::with_capacity(list.len());
+ let mut prev_end: Option<&Json> = None;
+ for kj in list {
+ let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0);
+ let sv = kj.get("s").or(prev_end);
+ prev_end = kj.get("e");
+ let Some(v) = sv.and_then(conv) else { continue };
+ let mut key = Keyframe::new(im.tb.tick(t), v);
+ key.spatial_auto = false;
+ if p.hold_only || !key.value.interpolates() {
+ key = key.hold();
+ }
+ keys.push(key);
+ kept.push(kj);
+ }
+ if keys.is_empty() {
+ return;
+ }
+ let n = keys.len();
+ for i in 0..n.saturating_sub(1) {
+ let Some(kj) = kept.get(i) else { break };
+ if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only {
+ keys[i].out_interp = Interp::Hold;
+ keys[i + 1].in_interp = Interp::Hold;
+ continue;
+ }
+ if spatial {
+ let to = vec3_of(kj.get("to"));
+ let ti = vec3_of(kj.get("ti"));
+ keys[i].spatial_out = to;
+ keys[i + 1].spatial_in = ti;
+ }
+ }
+ // Eases need the spatial tangents (arc length), so they come second.
+ for i in 0..n.saturating_sub(1) {
+ let Some(kj) = kept.get(i) else { break };
+ if keys[i].out_interp == Interp::Hold {
+ continue;
+ }
+ let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else {
+ keys[i].out_interp = Interp::Linear;
+ keys[i + 1].in_interp = Interp::Linear;
+ continue;
+ };
+ let dur = (secs(keys[i + 1].time) - secs(keys[i].time)).max(1e-12);
+ let ca = keys[i].value.components();
+ let cb = keys[i + 1].value.components();
+ let numeric = !ca.is_empty() && ca.len() == cb.len();
+ let dims = if spatial || !numeric { 1 } else { ca.len() };
+ let mut linear = true;
+ let mut outs = vec![];
+ let mut ins = vec![];
+ for d in 0..dims {
+ let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0);
+ let oy = comp(o, "y", d).unwrap_or(ox);
+ let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0);
+ let iy = comp(ii, "y", d).unwrap_or(ix);
+ if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 {
+ linear = false;
+ }
+ let dv = if spatial {
+ kf::spatial_segment_length(&keys, i)
+ } else if numeric {
+ cb[d] - ca[d]
+ } else {
+ 1.0
+ };
+ let inf_o = ox.max(0.001);
+ let inf_i = (1.0 - ix).max(0.001);
+ outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o });
+ ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i });
+ }
+ if linear {
+ keys[i].out_interp = Interp::Linear;
+ keys[i + 1].in_interp = Interp::Linear;
+ } else {
+ keys[i].out_interp = Interp::Bezier;
+ keys[i].out_ease = outs;
+ keys[i + 1].in_interp = Interp::Bezier;
+ keys[i + 1].in_ease = ins;
+ }
+ }
+ p.value = keys[0].value.clone();
+ p.keys = keys;
+}
+
+// ---------------------------------------------------------------- value converters
+
+pub(crate) fn num(v: &Value) -> Json {
+ json!(r6(v.as_f64()))
+}
+
+pub(crate) fn arr(v: &Value) -> Json {
+ match v {
+ Value::Scalar(x) => json!([r6(*x)]),
+ _ => json!(v.components().iter().map(|x| r6(*x)).collect::>()),
+ }
+}
+
+/// Vec2 (first two components of any vector).
+pub(crate) fn arr2(v: &Value) -> Json {
+ let c = v.components();
+ json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0))])
+}
+
+/// Vec3 (padded with `z`).
+pub(crate) fn arr3(v: &Value) -> Json {
+ let c = v.components();
+ json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))])
+}
+
+/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`).
+pub(crate) fn nums(j: &Json) -> Option> {
+ match j {
+ Json::Number(n) => Some(vec![n.as_f64()?]),
+ Json::Array(a) => {
+ // `[[x, y]]`-style wrapping.
+ if a.len() == 1
+ && let Some(inner @ Json::Array(_)) = a.first()
+ {
+ return nums(inner);
+ }
+ a.iter().map(Json::as_f64).collect()
+ }
+ _ => None,
+ }
+}
+
+pub(crate) fn to_scalar(j: &Json) -> Option {
+ nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
+}
+
+pub(crate) fn to_vec2(j: &Json) -> Option {
+ let v = nums(j)?;
+ Some(Value::Vec2([*v.first()?, *v.get(1).unwrap_or(&0.0)]))
+}
+
+/// Vec3 with a default third component.
+pub(crate) fn to_vec3(j: &Json, z: f64) -> Option {
+ let v = nums(j)?;
+ Some(Value::Vec3([*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&z)]))
+}
+
+pub(crate) fn to_color(j: &Json) -> Option {
+ let v = nums(j)?;
+ let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)];
+ // Some files use 0–255.
+ if c[..3].iter().any(|x| *x > 1.0) {
+ for x in &mut c[..3] {
+ *x /= 255.0;
+ }
+ }
+ Some(Value::Color(c))
+}
+
+pub(crate) fn to_bool(j: &Json) -> Option {
+ match j {
+ Json::Bool(b) => Some(Value::Bool(*b)),
+ _ => to_scalar(j).map(|v| Value::Bool(v.as_f64() != 0.0)),
+ }
+}
+
+/// Lottie 1-based enum → 0-based popup index.
+pub(crate) fn to_enum1(j: &Json) -> Option {
+ to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
+}
diff --git a/crates/lottie/src/base64.rs b/crates/lottie/src/base64.rs
new file mode 100644
index 00000000..b45f86d4
--- /dev/null
+++ b/crates/lottie/src/base64.rs
@@ -0,0 +1,69 @@
+//! Standard base64 (RFC 4648 §4) for embedded image assets.
+
+const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+pub fn encode(data: &[u8]) -> String {
+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
+ for chunk in data.chunks(3) {
+ let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
+ let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
+ for i in 0..4 {
+ if i <= chunk.len() {
+ out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
+ } else {
+ out.push('=');
+ }
+ }
+ }
+ out
+}
+
+pub fn decode(s: &str) -> Option> {
+ let val = |c: u8| -> Option {
+ Some(match c {
+ b'A'..=b'Z' => c - b'A',
+ b'a'..=b'z' => c - b'a' + 26,
+ b'0'..=b'9' => c - b'0' + 52,
+ b'+' | b'-' => 62,
+ b'/' | b'_' => 63,
+ _ => return None,
+ } as u32)
+ };
+ let clean: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
+ let mut out = Vec::with_capacity(clean.len() * 3 / 4);
+ for chunk in clean.chunks(4) {
+ let mut n = 0u32;
+ for (i, &c) in chunk.iter().enumerate() {
+ n |= val(c)? << (18 - 6 * i);
+ }
+ let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
+ match chunk.len() {
+ 4 => out.extend_from_slice(&bytes),
+ 3 => out.extend_from_slice(&bytes[..2]),
+ 2 => out.push(bytes[0]),
+ _ => return None,
+ }
+ }
+ Some(out)
+}
+
+/// Split a `data:;base64,` URI into (mime, bytes).
+pub fn data_uri(s: &str) -> Option<(String, Vec)> {
+ let rest = s.strip_prefix("data:")?;
+ let (head, payload) = rest.split_once(',')?;
+ let mime = head.strip_suffix(";base64")?;
+ Some((mime.to_string(), decode(payload)?))
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn roundtrip() {
+ for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] {
+ let e = super::encode(s.as_bytes());
+ assert_eq!(super::decode(&e).unwrap(), s.as_bytes());
+ }
+ assert_eq!(super::encode(b"foobar"), "Zm9vYmFy");
+ assert_eq!(super::encode(b"fo"), "Zm8=");
+ }
+}
diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs
new file mode 100644
index 00000000..fa5447d8
--- /dev/null
+++ b/crates/lottie/src/export.rs
@@ -0,0 +1,513 @@
+//! EffectCraft composition → Lottie document.
+
+use std::collections::BTreeSet;
+
+use effectcraft_keyframe::Value;
+use effectcraft_project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, PropGroup};
+use serde_json::{Map, Value as Json, json};
+
+use crate::anim::{self, Ex, export_prop};
+use crate::{LottieError, Timebase, shapes, text};
+
+/// Export settings.
+#[derive(Clone, Debug, Default)]
+pub struct ExportOptions {
+ /// Export expressions as Lottie `x` strings (players that run expressions evaluate them).
+ pub include_expressions: bool,
+ /// Export text layers as glyph shapes (exact look, no font needed) instead of text layers.
+ pub text_as_shapes: bool,
+}
+
+/// An exported Lottie document and what could not be expressed.
+#[derive(Clone, Debug)]
+pub struct ExportResult {
+ pub json: Json,
+ pub warnings: Vec,
+}
+
+impl ExportResult {
+ pub fn to_string_compact(&self) -> String {
+ serde_json::to_string(&self.json).unwrap_or_default()
+ }
+}
+
+/// One supported effect: Lottie effect type and its parameter list in Lottie order:
+/// (EffectCraft param id or "" for a Lottie-only param, Lottie value type, scale to Lottie,
+/// constant value for Lottie-only params).
+pub(crate) struct FxMap {
+ pub id: &'static str,
+ ty: u32,
+ params: &'static [(&'static str, u32, f64, f64)],
+}
+
+/// The effects Lottie players implement.
+const EFFECTS: &[FxMap] = &[
+ FxMap { id: "ec.blur.gaussian", ty: 29, params: &[("blurriness", 0, 1.0, 0.0), ("dimensions", 7, 1.0, 0.0), ("repeatEdge", 4, 1.0, 0.0)] },
+ FxMap { id: "ec.color.tint", ty: 20, params: &[("black", 2, 1.0, 0.0), ("white", 2, 1.0, 0.0), ("amount", 0, 1.0, 0.0)] },
+ FxMap {
+ id: "ec.color.tritone",
+ ty: 23,
+ params: &[("highlights", 2, 1.0, 0.0), ("midtones", 2, 1.0, 0.0), ("shadows", 2, 1.0, 0.0), ("blend", 0, 1.0, 0.0)],
+ },
+ FxMap {
+ id: "ec.perspective.dropshadow",
+ ty: 25,
+ params: &[
+ ("color", 2, 1.0, 0.0),
+ ("opacity", 0, 2.55, 0.0),
+ ("direction", 1, 1.0, 0.0),
+ ("distance", 0, 1.0, 0.0),
+ ("softness", 0, 1.0, 0.0),
+ ("shadowOnly", 4, 1.0, 0.0),
+ ],
+ },
+ FxMap {
+ id: "ec.generate.fill",
+ ty: 21,
+ params: &[
+ ("", 10, 1.0, 0.0),
+ ("", 4, 1.0, 1.0),
+ ("color", 2, 1.0, 0.0),
+ ("invert", 4, 1.0, 0.0),
+ ("", 0, 1.0, 0.0),
+ ("", 0, 1.0, 0.0),
+ ("opacity", 0, 0.01, 0.0),
+ ],
+ },
+ FxMap { id: "ec.control.slider", ty: 5, params: &[("slider", 0, 1.0, 0.0)] },
+ FxMap { id: "ec.control.angle", ty: 5, params: &[("angle", 1, 1.0, 0.0)] },
+ FxMap { id: "ec.control.color", ty: 5, params: &[("color", 2, 1.0, 0.0)] },
+ FxMap { id: "ec.control.point", ty: 5, params: &[("point", 3, 1.0, 0.0)] },
+ FxMap { id: "ec.control.checkbox", ty: 5, params: &[("checkbox", 4, 1.0, 0.0)] },
+];
+
+pub(crate) fn fx_by_id(id: &str) -> Option<&'static FxMap> {
+ EFFECTS.iter().find(|f| f.id == id)
+}
+
+pub(crate) fn fx_by_type(ty: u64, inner_ty: Option) -> Option<&'static FxMap> {
+ if ty == 5 {
+ return EFFECTS.iter().find(|f| f.ty == 5 && Some(f.params[0].1 as u64) == inner_ty);
+ }
+ EFFECTS.iter().find(|f| f.ty as u64 == ty)
+}
+
+pub(crate) fn fx_params(f: &FxMap) -> &'static [(&'static str, u32, f64, f64)] {
+ f.params
+}
+
+fn mask_mode(m: MaskMode) -> &'static str {
+ match m {
+ MaskMode::None => "n",
+ MaskMode::Add => "a",
+ MaskMode::Subtract => "s",
+ MaskMode::Intersect => "i",
+ MaskMode::Lighten => "l",
+ MaskMode::Darken => "d",
+ MaskMode::Difference => "f",
+ }
+}
+
+pub(crate) fn mask_mode_from(s: &str) -> MaskMode {
+ match s {
+ "n" => MaskMode::None,
+ "s" => MaskMode::Subtract,
+ "i" => MaskMode::Intersect,
+ "l" => MaskMode::Lighten,
+ "d" => MaskMode::Darken,
+ "f" => MaskMode::Difference,
+ _ => MaskMode::Add,
+ }
+}
+
+pub(crate) fn matte_code(k: MatteKind) -> u32 {
+ match k {
+ MatteKind::Alpha => 1,
+ MatteKind::AlphaInverted => 2,
+ MatteKind::Luma => 3,
+ MatteKind::LumaInverted => 4,
+ }
+}
+
+struct Doc<'a> {
+ project: &'a Project,
+ opts: &'a ExportOptions,
+ read: &'a dyn Fn(&str) -> Option>,
+ ex: Ex,
+ assets: Vec,
+ done: BTreeSet,
+ fonts: Vec,
+ any_3d: bool,
+}
+
+fn hex(c: [f32; 3]) -> String {
+ let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
+ format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
+}
+
+fn mime_of(path: &str) -> &'static str {
+ let lower = path.to_ascii_lowercase();
+ if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
+ "image/jpeg"
+ } else if lower.ends_with(".webp") {
+ "image/webp"
+ } else if lower.ends_with(".gif") {
+ "image/gif"
+ } else {
+ "image/png"
+ }
+}
+
+impl Doc<'_> {
+ fn frame(&self, t: effectcraft_time::Tick) -> f64 {
+ self.ex.tb.frame(t)
+ }
+
+ fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option {
+ let p = g.get(m)?;
+ Some(export_prop(&mut self.ex, p, conv, &format!("{label} ▸ {}", p.name)))
+ }
+
+ fn transform(&mut self, l: &Layer) -> Json {
+ let mut ks = Map::new();
+ let Some(tr) = l.transform() else { return json!({}) };
+ let label = format!("{} ▸ Transform", l.name);
+ let three = l.is_3d();
+ if let Some(a) = self.prop(tr, "anchor", &anim::arr3, &label) {
+ ks.insert("a".into(), a);
+ }
+ if tr.get("positionX").is_some() {
+ let mut p = json!({"s": true});
+ for (k, m) in [("x", "positionX"), ("y", "positionY"), ("z", "positionZ")] {
+ if (k != "z" || three)
+ && let Some(v) = self.prop(tr, m, &anim::num, &label)
+ {
+ p[k] = v;
+ }
+ }
+ ks.insert("p".into(), p);
+ } else if let Some(p) = self.prop(tr, "position", &anim::arr3, &label) {
+ ks.insert("p".into(), p);
+ }
+ if let Some(s) = self.prop(tr, "scale", &anim::arr3, &label) {
+ ks.insert("s".into(), s);
+ }
+ if three {
+ for (k, m) in [("rx", "rotationX"), ("ry", "rotationY"), ("rz", "rotation")] {
+ if let Some(v) = self.prop(tr, m, &anim::num, &label) {
+ ks.insert(k.into(), v);
+ }
+ }
+ if let Some(v) = self.prop(tr, "orientation", &anim::arr3, &label) {
+ ks.insert("or".into(), v);
+ }
+ } else if let Some(r) = self.prop(tr, "rotation", &anim::num, &label) {
+ ks.insert("r".into(), r);
+ }
+ if let Some(o) = self.prop(tr, "opacity", &anim::num, &label) {
+ ks.insert("o".into(), o);
+ }
+ Json::Object(ks)
+ }
+
+ fn masks(&mut self, l: &Layer) -> Vec {
+ let Some(masks) = l.masks() else { return vec![] };
+ let mut out = vec![];
+ for m in masks.groups() {
+ let GroupKind::Mask { mode, inverted, .. } = m.kind else { continue };
+ let label = format!("{} ▸ {}", l.name, m.name);
+ let mut o = json!({"nm": m.name, "mode": mask_mode(mode), "inv": inverted});
+ if let Some(v) = self.prop(m, "path", &shapes::path_value, &label) {
+ o["pt"] = v;
+ }
+ if let Some(v) = self.prop(m, "opacity", &anim::num, &label) {
+ o["o"] = v;
+ }
+ if let Some(v) = self.prop(m, "expansion", &anim::num, &label) {
+ o["x"] = v;
+ }
+ if let Some(f) = m.get("feather") {
+ if f.is_animated() || f.value.components().iter().any(|v| *v != 0.0) {
+ self.ex.warn(format!("{label}: mask feather is exported as `f` (not every Lottie player renders it)"));
+ }
+ o["f"] = export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather"));
+ }
+ if !m.enabled {
+ o["mode"] = json!("n");
+ }
+ out.push(o);
+ }
+ out
+ }
+
+ fn effects(&mut self, l: &Layer) -> Vec {
+ let Some(fx) = l.effects() else { return vec![] };
+ let mut out = vec![];
+ for (i, g) in fx.groups().enumerate() {
+ let GroupKind::Effect { effect } = &g.kind else { continue };
+ let label = format!("{} ▸ {}", l.name, g.name);
+ let Some(map) = fx_by_id(effect) else {
+ self.ex.warn(format!("{label}: effect not supported by Lottie (skipped)"));
+ continue;
+ };
+ let mut params = vec![];
+ for (pi, (m, ty, scale, konst)) in map.params.iter().enumerate() {
+ let (nm, v) = match g.get(m).filter(|_| !m.is_empty()) {
+ Some(p) => {
+ let (ty, scale) = (*ty, *scale);
+ let conv = move |v: &Value| -> Json {
+ match ty {
+ 2 => anim::arr(v),
+ 3 => anim::arr2(v),
+ 4 => json!(v.as_bool() as u8),
+ 7 => json!(v.as_enum() + 1),
+ _ => json!(v.as_f64() * scale),
+ }
+ };
+ (p.name.clone(), export_prop(&mut self.ex, p, &conv, &format!("{label} ▸ {}", p.name)))
+ }
+ None => (format!("Param {}", pi + 1), json!({"a": 0, "k": konst})),
+ };
+ params.push(json!({"ty": ty, "nm": nm, "mn": m, "ix": pi + 1, "v": v}));
+ }
+ out.push(json!({"ty": map.ty, "nm": g.name, "mn": effect, "ix": i + 1, "en": g.enabled as u8, "np": params.len(), "ef": params}));
+ }
+ out
+ }
+
+ fn image_asset(&mut self, item: ItemId, l: &Layer) -> Option {
+ let id = format!("image_{}", item.0);
+ if self.done.contains(&id) {
+ return Some(id);
+ }
+ let it = self.project.item(item)?;
+ let ItemKind::Footage(f) = &it.kind else { return None };
+ let Some(bytes) = (self.read)(&f.path) else {
+ self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path));
+ return None;
+ };
+ let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes));
+ self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1}));
+ self.done.insert(id.clone());
+ Some(id)
+ }
+
+ fn precomp_asset(&mut self, item: ItemId) -> Option {
+ let id = format!("comp_{}", item.0);
+ if self.done.contains(&id) {
+ return Some(id);
+ }
+ let it = self.project.item(item)?;
+ let c = it.as_comp()?;
+ self.done.insert(id.clone());
+ let layers = self.layers(c);
+ self.assets.push(json!({"id": id, "nm": it.name, "fr": c.frame_rate.as_f64(), "w": c.width, "h": c.height, "layers": layers}));
+ Some(id)
+ }
+
+ fn layers(&mut self, comp: &Comp) -> Vec {
+ let mattes: BTreeSet<_> = comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
+ let mut out = vec![];
+ for (idx, l) in comp.layers.iter().enumerate() {
+ out.push(self.layer(comp, idx, l, mattes.contains(&l.id)));
+ }
+ out
+ }
+
+ fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> Json {
+ let name = l.name.clone();
+ let mut o = Map::new();
+ o.insert("ind".into(), json!(idx + 1));
+ o.insert("nm".into(), json!(name));
+ let three = l.is_3d();
+ if three {
+ self.any_3d = true;
+ if !l.is_camera() && !l.is_light() {
+ self.ex.warn(format!("{name}: 3D layer exported with ddd: 1 (most Lottie players render 2D only)"));
+ }
+ }
+ o.insert("ddd".into(), json!(three as u8));
+ let mut hidden = !l.switches.video || l.switches.guide;
+ let mut ty = 3;
+ match &l.source {
+ LayerSource::Shape => {
+ ty = 4;
+ let items = l.props.sub("contents").map(|c| shapes::export_items(&mut self.ex, c, &name)).unwrap_or_default();
+ o.insert("shapes".into(), json!(items));
+ }
+ LayerSource::Text => {
+ if self.opts.text_as_shapes {
+ ty = 4;
+ let items = text::glyph_shapes(&mut self.ex, l);
+ o.insert("shapes".into(), json!(items));
+ } else {
+ ty = 5;
+ let t = text::export_text(&mut self.ex, l, &mut self.fonts);
+ o.insert("t".into(), t);
+ }
+ }
+ LayerSource::Solid { item } => {
+ let sol = self.project.item(*item).and_then(|i| if let ItemKind::Solid(s) = &i.kind { Some(s.clone()) } else { None });
+ match sol {
+ Some(s) if !l.switches.adjustment => {
+ ty = 1;
+ o.insert("sc".into(), json!(hex(s.color)));
+ o.insert("sw".into(), json!(s.width));
+ o.insert("sh".into(), json!(s.height));
+ }
+ _ => {
+ self.ex.warn(format!("{name}: adjustment layers are not part of Lottie (exported as a null)"));
+ hidden = true;
+ }
+ }
+ }
+ LayerSource::Comp { item } => {
+ if let Some(id) = self.precomp_asset(*item) {
+ ty = 0;
+ let (w, h) = self.project.comp(*item).map(|c| (c.width, c.height)).unwrap_or((comp.width, comp.height));
+ o.insert("refId".into(), json!(id));
+ o.insert("w".into(), json!(w));
+ o.insert("h".into(), json!(h));
+ if let Some(tm) = l.props.get("timeRemap") {
+ let v = export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap"));
+ o.insert("tm".into(), v);
+ }
+ }
+ }
+ LayerSource::Footage { item } => {
+ let kind = self.project.item(*item).and_then(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.has_video)) } else { None });
+ match kind {
+ Some((FootageKind::Still, true)) => {
+ if let Some(id) = self.image_asset(*item, l) {
+ ty = 2;
+ o.insert("refId".into(), json!(id));
+ } else {
+ hidden = true;
+ }
+ }
+ Some((FootageKind::Audio, _)) | Some((_, false)) => {
+ self.ex.warn(format!("{name}: audio is not part of Lottie (layer exported as a hidden null)"));
+ hidden = true;
+ }
+ _ => {
+ self.ex.warn(format!("{name}: video and image-sequence footage is not part of Lottie (layer exported as a null)"));
+ hidden = true;
+ }
+ }
+ }
+ LayerSource::Null => {}
+ LayerSource::Camera | LayerSource::Light { .. } => {
+ self.ex.warn(format!("{name}: cameras and lights are not exported (3D; exported as a hidden null to keep parenting)"));
+ hidden = true;
+ }
+ }
+ o.insert("ty".into(), json!(ty));
+ if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) {
+ o.insert("parent".into(), json!(p));
+ }
+ o.insert("sr".into(), json!(l.stretch / 100.0));
+ if l.stretch < 0.0 {
+ self.ex.warn(format!("{name}: reversed time stretch is not supported by most Lottie players"));
+ }
+ let ks = self.transform(l);
+ o.insert("ks".into(), ks);
+ o.insert("ao".into(), json!((l.auto_orient == effectcraft_project::AutoOrient::AlongPath) as u8));
+ o.insert("ip".into(), json!(self.frame(l.in_point)));
+ o.insert("op".into(), json!(self.frame(l.out_point)));
+ o.insert("st".into(), json!(self.frame(l.start_time)));
+ let bm = match crate::blend_to_lottie(l.blend_mode) {
+ Some(b) => b,
+ None => {
+ self.ex.warn(format!("{name}: blend mode {:?} has no Lottie equivalent (Normal used)", l.blend_mode));
+ 0
+ }
+ };
+ o.insert("bm".into(), json!(bm));
+ let masks = self.masks(l);
+ if !masks.is_empty() {
+ o.insert("hasMask".into(), json!(true));
+ o.insert("masksProperties".into(), json!(masks));
+ }
+ let ef = self.effects(l);
+ if !ef.is_empty() {
+ o.insert("ef".into(), json!(ef));
+ }
+ if let Some(tm) = l.track_matte
+ && let Some(mi) = comp.index_of(tm.layer)
+ {
+ o.insert("tt".into(), json!(matte_code(tm.kind)));
+ o.insert("tp".into(), json!(mi));
+ if mi != idx {
+ self.ex.warn(format!("{name}: track matte layer is not directly above (players without `tp` support use the layer above)"));
+ }
+ }
+ if is_matte {
+ o.insert("td".into(), json!(1));
+ if l.switches.video {
+ self.ex.warn(format!("{name}: visible track matte layer (Lottie always hides matte layers)"));
+ }
+ } else if hidden {
+ o.insert("hd".into(), json!(true));
+ }
+ if !l.markers.is_empty() {
+ self.ex.warn(format!("{name}: layer markers are not part of Lottie (composition markers are exported)"));
+ }
+ if l.props.sub(effectcraft_project::styles::GROUP).is_some_and(|g| g.groups().any(|s| s.enabled && s.match_id != effectcraft_project::styles::BLENDING))
+ {
+ self.ex.warn(format!("{name}: layer styles are not exported"));
+ }
+ if l.preserve_transparency {
+ self.ex.warn(format!("{name}: Preserve Underlying Transparency is not part of Lottie"));
+ }
+ Json::Object(o)
+ }
+}
+
+/// Export composition `comp` as a Lottie document. `read` loads footage files (still images are
+/// embedded as base64 assets).
+pub fn export_comp(project: &Project, comp: ItemId, opts: &ExportOptions, read: &dyn Fn(&str) -> Option>) -> Result {
+ let item = project.item(comp).ok_or_else(|| LottieError::Invalid(format!("no item {}", comp.0)))?;
+ let c = item.as_comp().ok_or_else(|| LottieError::Invalid(format!("`{}` is not a composition", item.name)))?;
+ let tb = Timebase { rate: c.frame_rate };
+ let mut doc = Doc {
+ project,
+ opts,
+ read,
+ ex: Ex { tb, include_expressions: opts.include_expressions, warnings: vec![] },
+ assets: vec![],
+ done: BTreeSet::new(),
+ fonts: vec![],
+ any_3d: false,
+ };
+ doc.done.insert(format!("comp_{}", comp.0));
+ let layers = doc.layers(c);
+ let markers: Vec = c.markers.iter().map(|m| json!({"tm": doc.frame(m.time), "cm": m.comment, "dr": doc.frame(m.duration)})).collect();
+ let mut out = json!({
+ "v": "5.12.0",
+ "fr": c.frame_rate.as_f64(),
+ "ip": 0,
+ "op": doc.frame(c.duration),
+ "w": c.width,
+ "h": c.height,
+ "nm": item.name,
+ "ddd": doc.any_3d as u8,
+ "assets": doc.assets,
+ "layers": layers,
+ "markers": markers,
+ });
+ if !doc.fonts.is_empty() {
+ out["fonts"] = json!({"list": doc.fonts});
+ }
+ Ok(ExportResult { json: out, warnings: doc.ex.warnings })
+}
+
+/// Package an export as a dotLottie archive (`manifest.json` + `animations/.json`).
+pub fn to_dotlottie(res: &ExportResult, id: &str) -> Vec {
+ let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
+ let id = if id.is_empty() { "animation".to_string() } else { id };
+ let manifest = json!({"version": "1", "generator": "EffectCraft", "author": "", "animations": [{"id": id, "speed": 1, "loop": true}]});
+ crate::zip::store(&[
+ ("manifest.json".into(), serde_json::to_vec(&manifest).unwrap_or_default()),
+ (format!("animations/{id}.json"), serde_json::to_vec(&res.json).unwrap_or_default()),
+ ])
+}
diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs
new file mode 100644
index 00000000..9ad38f39
--- /dev/null
+++ b/crates/lottie/src/import.rs
@@ -0,0 +1,463 @@
+//! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]).
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use effectcraft_color::Label;
+use effectcraft_keyframe::{ShapePath, Value};
+use effectcraft_project::build::{self, Ids};
+use effectcraft_project::{
+ AlphaMode, AutoOrient, Comp, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MatteKind, Node, ParamUi, Project,
+ PropGroup, Property, Solid, TrackMatte,
+};
+use effectcraft_time::{FrameRate, Tick};
+use serde_json::Value as Json;
+
+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};
+
+/// An embedded image asset handed to the caller to store (the returned path becomes the
+/// footage item's file).
+#[derive(Clone, Debug)]
+pub struct ImportedImage {
+ pub id: String,
+ pub name: String,
+ pub bytes: Vec,
+ /// File extension from the data URI (`png`, `jpg`, …).
+ pub ext: String,
+}
+
+#[derive(Clone, Debug)]
+pub struct ImportResult {
+ /// The new top-level composition.
+ pub comp: ItemId,
+ /// Every item created (comps, solids, images, folder).
+ pub items: Vec,
+ pub warnings: Vec,
+}
+
+fn f(j: &Json, k: &str) -> Option {
+ j.get(k).and_then(Json::as_f64)
+}
+
+fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> {
+ j.get(k).and_then(Json::as_str)
+}
+
+fn hex_color(sc: &str) -> [f32; 3] {
+ let h = sc.trim_start_matches('#');
+ let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0;
+ [c(0), c(2), c(4)]
+}
+
+/// The Lottie JSON inside raw bytes (plain JSON, or a stored dotLottie archive).
+fn document(bytes: &[u8]) -> Result {
+ if bytes.starts_with(b"PK") {
+ let entries = crate::zip::read_stored(bytes);
+ let anim = entries
+ .iter()
+ .find(|(n, _)| n.starts_with("animations/") && n.ends_with(".json"))
+ .or_else(|| entries.iter().find(|(n, _)| n.ends_with(".json") && n != "manifest.json"))
+ .ok_or_else(|| LottieError::Parse("dotLottie archive without an uncompressed animation".into()))?;
+ return serde_json::from_slice(&anim.1).map_err(|e| LottieError::Parse(e.to_string()));
+ }
+ serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string()))
+}
+
+struct Ctx<'a> {
+ im: Im,
+ assets: BTreeMap)>,
+ fonts: Vec<(String, String, String)>,
+ store: &'a mut dyn FnMut(&ImportedImage) -> Option,
+ base_dir: &'a str,
+ items: Vec,
+ folder: Option,
+ name: String,
+}
+
+impl Ctx<'_> {
+ fn folder(&mut self, project: &mut Project) -> ItemId {
+ if let Some(f) = self.folder {
+ return f;
+ }
+ let f = project.add_item(&format!("{} Assets", self.name), Label::None, None, ItemKind::Folder);
+ self.items.push(f);
+ self.folder = Some(f);
+ f
+ }
+}
+
+/// Import a Lottie document (JSON or a stored `.lottie` archive) into `project` as a new
+/// composition. `store` saves embedded images and returns the file path to use;
+/// `base_dir` resolves external image references.
+pub fn import(
+ project: &mut Project,
+ bytes: &[u8],
+ fallback_name: &str,
+ base_dir: &str,
+ store: &mut dyn FnMut(&ImportedImage) -> Option,
+) -> Result {
+ let doc = document(bytes)?;
+ let (Some(w), Some(h)) = (f(&doc, "w"), f(&doc, "h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) };
+ let fr = f(&doc, "fr").filter(|v| *v > 0.0).unwrap_or(30.0);
+ let rate = FrameRate::from_f64(fr);
+ let tb = Timebase { rate };
+ let name = s(&doc, "nm").filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string();
+ let ip = f(&doc, "ip").unwrap_or(0.0);
+ let op = f(&doc, "op").unwrap_or(ip + fr).max(ip + 1.0);
+ let mut cx =
+ 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 {
+ 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();
+ cx.fonts.push((n, fam, st));
+ }
+ }
+ let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
+ 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();
+ for a in &assets {
+ let Some(id) = s(a, "id") else { continue };
+ if a.get("layers").is_some() {
+ let folder = cx.folder(project);
+ let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h));
+ let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
+ let c = Comp::new(aw, ah, arate, duration);
+ let nm = s(a, "nm").unwrap_or(id).to_string();
+ let item = project.add_item(&nm, Label::Sandstone, Some(folder), ItemKind::Comp(Arc::new(c)));
+ cx.items.push(item);
+ cx.assets.insert(id.to_string(), (item, f(a, "w").zip(f(a, "h")).map(|(x, y)| (x as u32, y as u32))));
+ } else if let Some(p) = s(a, "p") {
+ let (aw, ah) = (f(a, "w").unwrap_or(0.0) as u32, f(a, "h").unwrap_or(0.0) as u32);
+ let nm = s(a, "nm").unwrap_or(id).to_string();
+ let path = if let Some((mime, data)) = crate::base64::data_uri(p) {
+ let ext = match mime.as_str() {
+ "image/jpeg" | "image/jpg" => "jpg",
+ "image/webp" => "webp",
+ "image/gif" => "gif",
+ "image/svg+xml" => "svg",
+ _ => "png",
+ };
+ (cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() })
+ } else {
+ let u = s(a, "u").unwrap_or("");
+ let rel = format!("{u}{p}");
+ Some(if rel.starts_with('/') || cx.base_dir.is_empty() { rel } else { format!("{}/{rel}", cx.base_dir.trim_end_matches('/')) })
+ };
+ let Some(path) = path else {
+ cx.im.warn(format!("image asset `{id}` could not be stored"));
+ continue;
+ };
+ let folder = cx.folder(project);
+ let foot = Footage {
+ path,
+ kind: FootageKind::Still,
+ width: aw.max(1),
+ height: ah.max(1),
+ pixel_aspect: 1.0,
+ frame_rate: rate,
+ native_rate: None,
+ duration: Tick::ZERO,
+ has_video: true,
+ has_audio: false,
+ alpha: AlphaMode::Straight,
+ premul_color: [0.0; 3],
+ loop_count: 1,
+ codec: String::new(),
+ missing: false,
+ sequence: vec![],
+ };
+ let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
+ cx.items.push(item);
+ cx.assets.insert(id.to_string(), (item, Some((aw, ah))));
+ } else {
+ cx.im.warn(format!("asset `{id}` is not supported (skipped)"));
+ }
+ }
+ // Pass 2: precomp contents.
+ for a in &assets {
+ let (Some(id), Some(layers)) = (s(a, "id"), a.get("layers").and_then(Json::as_array)) else { continue };
+ let Some((item, _)) = cx.assets.get(id).copied() else { continue };
+ let Some(mut c) = project.comp(item).cloned() else { continue };
+ let end = layers.iter().filter_map(|l| f(l, "op")).fold(0.0f64, f64::max);
+ if end > 0.0 {
+ c.duration = tb.tick(end);
+ c.work_area = (Tick::ZERO, c.duration);
+ }
+ build_layers(&mut cx, project, &mut c, layers);
+ if let Some(it) = project.item_mut(item) {
+ it.kind = ItemKind::Comp(Arc::new(c));
+ }
+ }
+ // The main comp.
+ let mut comp = Comp::new(w, h, rate, duration);
+ if ip > 0.0 {
+ comp.work_area = (tb.tick(ip), duration);
+ }
+ if let Some(ms) = doc.get("markers").and_then(Json::as_array) {
+ for m in ms {
+ comp.markers.push(Marker {
+ time: tb.tick(f(m, "tm").unwrap_or(0.0)),
+ duration: tb.tick(f(m, "dr").unwrap_or(0.0)),
+ comment: s(m, "cm").unwrap_or("").to_string(),
+ ..Default::default()
+ });
+ }
+ }
+ let layers = doc.get("layers").and_then(Json::as_array).cloned().unwrap_or_default();
+ build_layers(&mut cx, project, &mut comp, &layers);
+ let id = project.add_item(&name, Label::Sandstone, None, ItemKind::Comp(Arc::new(comp)));
+ cx.items.push(id);
+ project.fix_next_id();
+ Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
+}
+
+fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
+ let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option| {
+ if let (Some(j), Some(p)) = (j, tr.get_mut(m)) {
+ import_prop(im, j, p, conv);
+ }
+ };
+ set(tr, "anchor", ks.get("a"), &|v| anim::to_vec3(v, 0.0));
+ set(tr, "scale", ks.get("s"), &|v| anim::to_vec3(v, 100.0));
+ set(tr, "rotation", ks.get("r").or(ks.get("rz")), &anim::to_scalar);
+ set(tr, "rotationX", ks.get("rx"), &anim::to_scalar);
+ set(tr, "rotationY", ks.get("ry"), &anim::to_scalar);
+ set(tr, "orientation", ks.get("or"), &|v| anim::to_vec3(v, 0.0));
+ set(tr, "opacity", ks.get("o"), &anim::to_scalar);
+ let Some(p) = ks.get("p") else { return };
+ if p.get("s").and_then(Json::as_bool) == Some(true) {
+ // Separated dimensions: X/Y/Z Position after Position.
+ let at = tr.children.iter().position(|c| c.match_id() == "position").map(|i| i + 1).unwrap_or(tr.children.len());
+ let base = tr.get("position").map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
+ for (d, (key, m, name)) in
+ [("x", "positionX", "X Position"), ("y", "positionY", "Y Position"), ("z", "positionZ", "Z Position")].iter().enumerate().rev()
+ {
+ let mut pr = Property::new(*next, m, name, Value::Scalar(base[d])).with_ui(ParamUi::Number);
+ *next += 1;
+ pr.three_d_only = d == 2;
+ if let Some(j) = p.get(*key) {
+ import_prop(im, j, &mut pr, &anim::to_scalar);
+ }
+ tr.children.insert(at, Node::Prop(pr));
+ }
+ } else {
+ set(tr, "position", Some(p), &|v| anim::to_vec3(v, 0.0));
+ }
+}
+
+fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
+ for (i, m) in list.iter().enumerate() {
+ let mode = mask_mode_from(s(m, "mode").unwrap_or("a"));
+ let name = s(m, "nm").map(str::to_string).unwrap_or(format!("Mask {}", i + 1));
+ let color = build::MASK_COLORS[i % build::MASK_COLORS.len()];
+ let mut mg = build::mask(ids, &name, ShapePath::default(), mode, color);
+ if let GroupKind::Mask { inverted, .. } = &mut mg.kind {
+ *inverted = m.get("inv").and_then(Json::as_bool).unwrap_or(false);
+ }
+ let mut set = |k: &str, mid: &str, conv: &dyn Fn(&Json) -> Option| {
+ if let (Some(j), Some(p)) = (m.get(k), mg.get_mut(mid)) {
+ import_prop(im, j, p, conv);
+ }
+ };
+ set("pt", "path", &shapes::to_path);
+ set("o", "opacity", &anim::to_scalar);
+ set("x", "expansion", &anim::to_scalar);
+ set("f", "feather", &anim::to_vec2);
+ g.children.push(Node::Group(mg));
+ }
+}
+
+fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [f64; 2]) {
+ for e in list {
+ let ty = e.get("ty").and_then(Json::as_u64).unwrap_or(0);
+ let params = e.get("ef").and_then(Json::as_array).cloned().unwrap_or_default();
+ let inner_ty = params.first().and_then(|p| p.get("ty")).and_then(Json::as_u64);
+ let map = s(e, "mn").and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty));
+ let nm = s(e, "nm").unwrap_or("Effect");
+ let Some(map) = map else {
+ im.warn(format!("effect `{nm}` (type {ty}) is not supported (skipped)"));
+ continue;
+ };
+ let Some(spec) = effectcraft_effects::find(map.id) else { continue };
+ let mut fg = effectcraft_effects::instantiate(spec, ids, nm, size);
+ if e.get("en").and_then(Json::as_u64) == Some(0) {
+ fg.enabled = false;
+ }
+ for (i, (m, pty, scale, _)) in fx_params(map).iter().enumerate() {
+ if m.is_empty() {
+ continue;
+ }
+ let Some(pj) = params.get(i).and_then(|p| p.get("v")) else { continue };
+ let Some(p) = fg.get_mut(m) else { continue };
+ let (pty, scale) = (*pty, *scale);
+ let conv = move |v: &Json| -> Option {
+ match pty {
+ 2 => anim::to_color(v),
+ 3 => anim::to_vec2(v),
+ 4 => anim::to_bool(v),
+ 7 => anim::to_enum1(v),
+ _ => anim::to_scalar(v).map(|x| Value::Scalar(x.as_f64() / scale)),
+ }
+ };
+ import_prop(im, pj, p, &conv);
+ }
+ g.children.push(Node::Group(fg));
+ }
+}
+
+fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> Option<(LayerSource, (u32, u32))> {
+ let ty = j.get("ty").and_then(Json::as_u64).unwrap_or(99);
+ let full = (comp.width, comp.height);
+ let asset = |cx: &Ctx| s(j, "refId").and_then(|r| cx.assets.get(r).copied());
+ Some(match ty {
+ 0 => {
+ let Some((item, _)) = asset(cx) else {
+ cx.im.warn(format!("{}: missing precomp asset", s(j, "nm").unwrap_or("layer")));
+ return None;
+ };
+ let size = project.comp(item).map(|c| (c.width, c.height)).unwrap_or(full);
+ (LayerSource::Comp { item }, size)
+ }
+ 1 => {
+ let sw = f(j, "sw").unwrap_or(comp.width as f64).round().max(1.0) as u32;
+ let sh = f(j, "sh").unwrap_or(comp.height as f64).round().max(1.0) as u32;
+ let color = hex_color(s(j, "sc").unwrap_or("#000000"));
+ let name = s(j, "nm").unwrap_or("Solid");
+ let folder = cx.folder(project);
+ let item = project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: sw, height: sh, pixel_aspect: 1.0 }));
+ cx.items.push(item);
+ (LayerSource::Solid { item }, (sw, sh))
+ }
+ 2 => {
+ let Some((item, size)) = asset(cx) else {
+ cx.im.warn(format!("{}: missing image asset", s(j, "nm").unwrap_or("layer")));
+ return None;
+ };
+ (LayerSource::Footage { item }, size.unwrap_or(full))
+ }
+ 3 => (LayerSource::Null, (100, 100)),
+ 4 => (LayerSource::Shape, full),
+ 5 => (LayerSource::Text, full),
+ 13 => {
+ cx.im.warn(format!("{}: camera layers are not imported", s(j, "nm").unwrap_or("camera")));
+ return None;
+ }
+ other => {
+ cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", s(j, "nm").unwrap_or("layer")));
+ return None;
+ }
+ })
+}
+
+/// Build the layers of a comp (Lottie order: first = top = layer #1).
+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![];
+ let mut pos_ind: Vec