M11.4: Lottie export/import

New crate effectcraft-lottie (L3): export a comp to Lottie JSON / dotLottie
(precomp and base64 image assets; shape, solid, null, precomp, image and text
layers or glyph shapes; transforms with parenting, separated position,
anchor; keyframes with speed/influence converted to normalised i/o tangents,
hold keys, spatial ti/to; all shape items incl. gradients, dashes, trim,
repeater and path operators; masks, track mattes, blend modes, time remap,
timing, markers, optional expressions, a few Lottie effects) with a warnings
list for what Lottie cannot express, and the inverse import. Commands
file.exportLottie / file.importLottie with File > Export / Import entries.
Round-trip tests (values and rendered frames) and hand-written fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:56:03 -04:00
parent 5273f1ba96
commit c4100bcf80
32 changed files with 3767 additions and 4 deletions

View File

@ -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/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`) | | `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 | | `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 ## Screenshots

18
Cargo.lock generated
View File

@ -1191,6 +1191,7 @@ dependencies = [
"effectcraft-effects", "effectcraft-effects",
"effectcraft-geom", "effectcraft-geom",
"effectcraft-keyframe", "effectcraft-keyframe",
"effectcraft-lottie",
"effectcraft-path", "effectcraft-path",
"effectcraft-project", "effectcraft-project",
"effectcraft-raster", "effectcraft-raster",
@ -1275,6 +1276,23 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "effectcraft-media" name = "effectcraft-media"
version = "0.1.0" version = "0.1.0"

View File

@ -25,6 +25,7 @@ effectcraft-render = { path = "crates/render" }
effectcraft-media = { path = "crates/media" } effectcraft-media = { path = "crates/media" }
effectcraft-export = { path = "crates/export" } effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" } effectcraft-expr = { path = "crates/expr" }
effectcraft-lottie = { path = "crates/lottie" }
effectcraft-engine = { path = "crates/engine" } effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" } effectcraft-host = { path = "crates/host" }
effectcraft-ui-egui = { path = "crates/ui-egui" } effectcraft-ui-egui = { path = "crates/ui-egui" }

View File

@ -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 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. 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.
<p align="center"> <p align="center">
<img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%"> <img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%">
</p> </p>
@ -154,7 +159,8 @@ them:
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json). tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
- **Command line:** one-shot calls with JSON output, for example - **Command line:** one-shot calls with JSON output, for example
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save` `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 - **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
and click any widget by its automation id, and take screenshots. 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 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, 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 ([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. are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.

View File

@ -17,7 +17,7 @@ once.
| M8 | Expressions with the After Effects object model | Done | | 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) | | 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 | | 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 | | 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 | | M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded | | M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |

View File

@ -20,6 +20,7 @@ effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true } effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true } effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true } effectcraft-render = { workspace = true }
effectcraft-lottie = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }

View File

@ -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<Value> {
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<Value> {
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 (`<name>_<asset id>.<ext>`).
let mut store = |img: &ImportedImage| -> Option<String> {
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::<Vec<_>>(), "warnings": res.warnings}))
}
pub fn specs() -> Vec<CommandSpec> {
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),
]
}

View File

@ -15,6 +15,7 @@ mod layer;
mod layer_menu; mod layer_menu;
mod layer_time; mod layer_time;
mod link; mod link;
mod lottie;
mod mask; mod mask;
mod prop; mod prop;
mod query; mod query;
@ -100,6 +101,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs()); v.extend(animation::specs());
v.extend(view::specs()); v.extend(view::specs());
v.extend(file_more::specs()); v.extend(file_more::specs());
v.extend(lottie::specs());
v.extend(comp_more::specs()); v.extend(comp_more::specs());
v.extend(frontend::specs()); v.extend(frontend::specs());
v.extend(stubs::specs()); v.extend(stubs::specs());

View File

@ -454,6 +454,8 @@ mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_3d; mod tests_3d;
#[cfg(test)] #[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_menu_cmds; mod tests_menu_cmds;
#[cfg(test)] #[cfg(test)]
mod tests_styles; mod tests_styles;

View File

@ -307,8 +307,10 @@ File
Multiple Files... | file.importMultiple Multiple Files... | file.importMultiple
Placeholder... | file.importPlaceholder Placeholder... | file.importPlaceholder
Solid... | file.importSolid Solid... | file.importSolid
Lottie... | file.importLottie
Export Export
Add to Render Queue | renderQueue.add Add to Render Queue | renderQueue.add
Lottie JSON... | file.exportLottie
--- ---
Find | app.find Find | app.find
--- ---

View File

@ -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())));
}

25
crates/lottie/Cargo.toml Normal file
View File

@ -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 }

376
crates/lottie/src/anim.rs Normal file
View File

@ -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<String>,
}
impl Ex {
pub fn warn(&mut self, w: impl Into<String>) {
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<String>,
}
impl Im {
pub fn warn(&mut self, w: impl Into<String>) {
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<Json> {
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::<Vec<_>>());
o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
}
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<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
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<f64> {
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<Value>) {
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<Keyframe> = 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::<Vec<_>>()),
}
}
/// 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<Vec<f64>> {
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<Value> {
nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
}
pub(crate) fn to_vec2(j: &Json) -> Option<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
}

View File

@ -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<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
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<u8> = 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:<mime>;base64,<payload>` URI into (mime, bytes).
pub fn data_uri(s: &str) -> Option<(String, Vec<u8>)> {
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=");
}
}

513
crates/lottie/src/export.rs Normal file
View File

@ -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<String>,
}
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<u64>) -> 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<Vec<u8>>,
ex: Ex,
assets: Vec<Json>,
done: BTreeSet<String>,
fonts: Vec<Json>,
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<Json> {
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<Json> {
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<Json> {
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<String> {
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<String> {
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<Json> {
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<Vec<u8>>) -> Result<ExportResult, LottieError> {
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<Json> = 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/<id>.json`).
pub fn to_dotlottie(res: &ExportResult, id: &str) -> Vec<u8> {
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()),
])
}

463
crates/lottie/src/import.rs Normal file
View File

@ -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<u8>,
/// 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<ItemId>,
pub warnings: Vec<String>,
}
fn f(j: &Json, k: &str) -> Option<f64> {
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<Json, LottieError> {
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<String, (ItemId, Option<(u32, u32)>)>,
fonts: Vec<(String, String, String)>,
store: &'a mut dyn FnMut(&ImportedImage) -> Option<String>,
base_dir: &'a str,
items: Vec<ItemId>,
folder: Option<ItemId>,
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<String>,
) -> Result<ImportResult, LottieError> {
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<Value>| {
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<Value>| {
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<Value> {
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<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![];
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);
pos_ind.push(ind);
let Some((source, size)) = layer_source(cx, project, comp, j) else { continue };
let name = s(j, "nm").unwrap_or(source.type_name()).to_string();
let mut l: Layer = build::layer(project, comp, &name, source.clone(), size, None);
if let Some(i) = ind {
by_ind.insert(i, l.id);
}
let ip = f(j, "ip").unwrap_or(0.0);
let op = f(j, "op").unwrap_or(tb.frame(comp.duration));
l.in_point = tb.tick(ip);
l.out_point = tb.tick(op.max(ip));
l.start_time = tb.tick(f(j, "st").unwrap_or(0.0));
l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0;
if j.get("ddd").and_then(Json::as_u64) == Some(1) {
l.switches.three_d = true;
}
if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
l.blend_mode = crate::blend_from_lottie(bm);
}
if j.get("ao").and_then(Json::as_u64) == Some(1) {
l.auto_orient = AutoOrient::AlongPath;
}
if j.get("hd").and_then(Json::as_bool) == Some(true) {
l.switches.video = false;
}
if j.get("td").and_then(Json::as_u64) == Some(1) {
l.switches.video = false;
}
let mut next = project.next_id;
if let (Some(ks), Some(tr)) = (j.get("ks"), l.props.sub_mut("transform")) {
transform(&cx.im, ks, tr, &mut next);
}
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);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Node::Group));
}
}
LayerSource::Text => {
if let (Some(t), Some(tg)) = (j.get("t"), l.props.sub_mut("text")) {
text::import_text(&mut cx.im, &mut ids, t, tg, &cx.fonts);
}
}
LayerSource::Comp { .. } => {
if let Some(tm) = j.get("tm") {
let mut pr = ids.prop("timeRemap", "Time Remap", Value::Scalar(0.0)).with_ui(ParamUi::Number);
import_prop(&cx.im, tm, &mut pr, &anim::to_scalar);
l.props.children.insert(0, Node::Prop(pr));
}
}
_ => {}
}
if let Some(list) = j.get("masksProperties").and_then(Json::as_array)
&& let Some(g) = l.props.sub_mut("masks")
{
masks(&cx.im, &mut ids, list, g);
}
if let Some(list) = j.get("ef").and_then(Json::as_array)
&& let Some(g) = l.props.sub_mut("effects")
{
effects(&mut cx.im, &mut ids, list, g, [size.0 as f64, size.1 as f64]);
}
if j.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) {
cx.im.warn(format!("{name}: layer styles are not imported"));
}
project.next_id = next;
let parent = j.get("parent").and_then(Json::as_i64);
let tt = j.get("tt").and_then(Json::as_u64).unwrap_or(0);
let matte = match tt {
1 => Some(MatteKind::Alpha),
2 => Some(MatteKind::AlphaInverted),
3 => Some(MatteKind::Luma),
4 => Some(MatteKind::LumaInverted),
_ => None,
}
.map(|k| (k, j.get("tp").and_then(Json::as_i64)));
pending.push((l.id, parent, matte, ji));
comp.layers.push(l);
}
for (id, 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 {
Some(tp) => by_ind.get(&tp).copied(),
// Legacy files: the matte is the layer directly above.
None => ji.checked_sub(1).and_then(|a| pos_ind.get(a).copied().flatten()).and_then(|i| by_ind.get(&i).copied()),
};
target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k })
});
if let Some(l) = comp.layer_mut(id) {
l.parent = parent_id;
l.track_matte = matte_id;
}
}
}

99
crates/lottie/src/lib.rs Normal file
View File

@ -0,0 +1,99 @@
//! Lottie JSON import and export.
//!
//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the
//! LottieFiles format documentation); no player or exporter source was used.
//!
//! **Export** ([`export_comp`]) turns a composition into a Lottie document: nested compositions
//! become precomp assets, still footage becomes embedded base64 image assets, and shape, solid,
//! null, precomp, image and text layers map to their Lottie layer types (text optionally as glyph
//! shapes). Transforms (with parenting, separated position and anchor), keyframes (temporal ease
//! converted from speed/influence to normalised Bezier `i`/`o` tangents, hold keys, spatial
//! `ti`/`to` tangents), shape contents, masks, track mattes, blend modes, time remapping, layer
//! timing and composition markers are exported. Anything Lottie cannot express (most effects,
//! cameras and lights, layer styles, audio, video footage…) is listed in
//! [`ExportResult::warnings`].
//!
//! **Import** ([`import`]) is the inverse mapping into a new composition.
//!
//! Keyframe times in Lottie are frames of the layer's local time, which is EffectCraft's layer
//! time; layer `ip`/`op`/`st` are composition frames and `sr` is the stretch factor.
mod anim;
mod base64;
mod export;
mod import;
mod shapes;
mod text;
mod zip;
use effectcraft_color::BlendMode;
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
pub use export::{ExportOptions, ExportResult, export_comp, to_dotlottie};
pub use import::{ImportResult, ImportedImage, import};
#[derive(Debug, thiserror::Error)]
pub enum LottieError {
#[error("not a Lottie document: {0}")]
Parse(String),
#[error("{0}")]
Invalid(String),
}
/// Frame ↔ tick conversion at a frame rate.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Timebase {
pub rate: FrameRate,
}
impl Timebase {
/// Frame number (fractional) of a time.
pub fn frame(&self, t: Tick) -> f64 {
let f = t.0 as f64 * self.rate.num as f64 / (self.rate.den as f64 * TICKS_PER_SECOND as f64);
// Keep exact frame numbers clean (no 23.999999).
if (f - f.round()).abs() < 1e-6 { f.round() } else { f }
}
pub fn tick(&self, f: f64) -> Tick {
Tick((f * TICKS_PER_SECOND as f64 * self.rate.den as f64 / self.rate.num as f64).round() as i64)
}
}
/// Lottie layer / shape blend modes (`bm`) in order.
const LOTTIE_BLEND: [BlendMode; 18] = [
BlendMode::Normal,
BlendMode::Multiply,
BlendMode::Screen,
BlendMode::Overlay,
BlendMode::Darken,
BlendMode::Lighten,
BlendMode::ColorDodge,
BlendMode::ColorBurn,
BlendMode::HardLight,
BlendMode::SoftLight,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
BlendMode::Add,
BlendMode::HardMix,
];
/// Lottie `bm` of a blend mode (`None` = Lottie has no equivalent).
pub(crate) fn blend_to_lottie(m: BlendMode) -> Option<u32> {
LOTTIE_BLEND.iter().position(|b| *b == m).map(|i| i as u32).or(match m {
BlendMode::LinearDodge => Some(16),
BlendMode::ClassicColorDodge => Some(6),
BlendMode::ClassicColorBurn => Some(7),
BlendMode::ClassicDifference => Some(10),
_ => None,
})
}
pub(crate) fn blend_from_lottie(bm: u64) -> BlendMode {
LOTTIE_BLEND.get(bm as usize).copied().unwrap_or_default()
}
#[cfg(test)]
mod tests;

578
crates/lottie/src/shapes.rs Normal file
View File

@ -0,0 +1,578 @@
//! Shape layer contents ↔ Lottie shape items (`gr`, `rc`, `el`, `sr`, `sh`, `fl`, `st`, `gf`,
//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`).
use effectcraft_color::BlendMode;
use effectcraft_keyframe::{Gradient, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Node, PropGroup};
use serde_json::{Map, Value as Json, json};
use crate::anim::{self, Ex, Im, export_prop, import_prop};
// ---------------------------------------------------------------- shared value converters
pub(crate) fn shape_json(p: &ShapePath) -> Json {
let pts = |v: &[[f64; 2]]| v.iter().map(|q| json!([r(q[0]), r(q[1])])).collect::<Vec<_>>();
json!({"c": p.closed, "v": pts(&p.vertices), "i": pts(&p.in_tangents), "o": pts(&p.out_tangents)})
}
fn r(v: f64) -> f64 {
(v * 1e6).round() / 1e6
}
pub(crate) fn path_value(v: &Value) -> Json {
match v {
Value::Path(p) => shape_json(p),
_ => shape_json(&ShapePath::default()),
}
}
pub(crate) fn to_path(j: &Json) -> Option<Value> {
let o = match j {
Json::Array(a) => a.first()?,
o => o,
};
let pts = |k: &str| -> Vec<[f64; 2]> {
o.get(k)
.and_then(Json::as_array)
.map(|a| a.iter().map(|p| anim::nums(p).map(|v| [*v.first().unwrap_or(&0.0), *v.get(1).unwrap_or(&0.0)]).unwrap_or([0.0; 2])).collect())
.unwrap_or_default()
};
let vertices = pts("v");
let n = vertices.len();
let fit = |mut v: Vec<[f64; 2]>| {
v.resize(n, [0.0; 2]);
v
};
Some(Value::Path(ShapePath {
in_tangents: fit(pts("i")),
out_tangents: fit(pts("o")),
vertices,
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
}))
}
/// Gradient as Lottie's flat stop list: `[pos, r, g, b]…` then `[pos, alpha]…`.
pub(crate) fn gradient_flat(v: &Value) -> Json {
let g = match v {
Value::Gradient(g) => g.clone(),
_ => Gradient::default(),
};
let mut out = vec![];
for (p, c) in &g.colors {
out.extend([r(*p), r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)]);
}
let plain = g.opacities.iter().all(|(_, a)| (*a - 1.0).abs() < 1e-6);
if !plain {
for (p, a) in &g.opacities {
out.extend([r(*p), r(*a as f64)]);
}
}
json!(out)
}
pub(crate) fn to_gradient(j: &Json, ncolors: usize) -> Option<Value> {
let v = anim::nums(j)?;
let n = ncolors.min(v.len() / 4);
let colors: Vec<(f64, [f32; 4])> = (0..n).map(|i| (v[i * 4], [v[i * 4 + 1] as f32, v[i * 4 + 2] as f32, v[i * 4 + 3] as f32, 1.0])).collect();
let rest = &v[n * 4..];
let mut opacities: Vec<(f64, f32)> = rest.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1] as f32)).collect();
if opacities.is_empty() {
opacities = vec![(0.0, 1.0), (1.0, 1.0)];
}
Some(Value::Gradient(Gradient { colors, opacities }))
}
fn blend_index(m: BlendMode) -> u32 {
BlendMode::ALL.iter().position(|b| *b == m).unwrap_or(0) as u32
}
// ---------------------------------------------------------------- export
struct Out<'a, 'b> {
ex: &'a mut Ex,
g: &'b PropGroup,
o: Map<String, Json>,
label: String,
}
impl Out<'_, '_> {
fn put(&mut self, key: &str, m: &str, conv: &dyn Fn(&Value) -> Json) {
if let Some(p) = self.g.get(m) {
let v = export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name));
self.o.insert(key.into(), v);
}
}
fn raw(&mut self, key: &str, v: Json) {
self.o.insert(key.into(), v);
}
fn e(&self, m: &str) -> u32 {
self.g.get(m).map(|p| p.value.as_enum()).unwrap_or(0)
}
fn f(&self, m: &str) -> f64 {
self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0)
}
fn animated(&self, m: &str) -> bool {
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
}
fn blend(&mut self) {
let i = self.e("blend");
let m = BlendMode::ALL.get(i as usize).copied().unwrap_or_default();
match crate::blend_to_lottie(m) {
Some(bm) => self.raw("bm", json!(bm)),
None => {
self.ex.warn(format!("{}: blend mode {m:?} has no Lottie equivalent (Normal used)", self.label));
self.raw("bm", json!(0));
}
}
}
fn done(self) -> Json {
Json::Object(self.o)
}
}
/// Stroke fields shared by `st` and `gs`.
fn stroke_fields(o: &mut Out) {
o.put("w", "width", &anim::num);
o.raw("lc", json!(o.e("cap") + 1));
o.raw("lj", json!(o.e("join") + 1));
o.raw("ml", json!(o.f("miter")));
if let Some(d) = o.g.sub("dashes") {
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
if dash > 0.0 || d.any_animated() {
let mut list = vec![];
for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] {
if let Some(p) = d.get(m) {
let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name));
list.push(json!({"n": n, "nm": p.name, "v": v}));
}
}
o.raw("d", json!(list));
}
}
}
fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Json {
let mut o = Out { ex, g: tr, o: Map::new(), label: format!("{label} ▸ Transform") };
o.raw("ty", json!("tr"));
o.put("a", "anchor", &anim::arr2);
o.put("p", "position", &anim::arr2);
o.put("s", "scale", &anim::arr2);
o.put("r", "rotation", &anim::num);
o.put("o", "opacity", &anim::num);
o.put("sk", "skew", &anim::num);
o.put("sa", "skewAxis", &anim::num);
o.put("so", "startOpacity", &anim::num);
o.put("eo", "endOpacity", &anim::num);
o.done()
}
/// Export a contents group's items (first item = top, as in the timeline).
pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec<Json> {
let mut out = vec![];
for node in &contents.children {
let Node::Group(g) = node else { continue };
let lbl = format!("{label} ▸ {}", g.name);
let ty = match g.match_id.as_str() {
"group" => "gr",
"rect" => "rc",
"ellipse" => "el",
"star" => "sr",
"path" => "sh",
"fill" => "fl",
"stroke" => "st",
"gfill" => "gf",
"gstroke" => "gs",
"trim" => "tm",
"repeater" => "rp",
"round" => "rd",
"offset" => "op",
"pucker" => "pb",
"twist" => "tw",
"zigzag" => "zz",
"merge" => "mm",
other => {
ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)"));
continue;
}
};
let mut o = Out { ex, g, o: Map::new(), label: lbl.clone() };
o.raw("ty", json!(ty));
o.raw("nm", json!(g.name));
if !g.enabled {
o.raw("hd", json!(true));
}
let dir = |o: &mut Out| {
let d = if o.e("direction") == 1 { 3 } else { 1 };
o.raw("d", json!(d));
};
match ty {
"gr" => {
let items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default();
let mut items = items;
let tr = match g.sub("transform") {
Some(t) => shape_transform(o.ex, t, &lbl),
None => {
json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}})
}
};
items.push(tr);
o.raw("np", json!(items.len()));
o.raw("it", json!(items));
o.blend();
}
"rc" => {
dir(&mut o);
o.put("s", "size", &anim::arr2);
o.put("p", "position", &anim::arr2);
o.put("r", "roundness", &anim::num);
}
"el" => {
dir(&mut o);
o.put("s", "size", &anim::arr2);
o.put("p", "position", &anim::arr2);
}
"sr" => {
dir(&mut o);
o.raw("sy", json!(o.e("type") + 1));
o.put("pt", "points", &anim::num);
o.put("p", "position", &anim::arr2);
o.put("r", "rotation", &anim::num);
o.put("ir", "innerRadius", &anim::num);
o.put("or", "outerRadius", &anim::num);
o.put("is", "innerRoundness", &anim::num);
o.put("os", "outerRoundness", &anim::num);
}
"sh" => {
dir(&mut o);
o.put("ks", "path", &path_value);
}
"fl" => {
o.put("c", "color", &anim::arr);
o.put("o", "opacity", &anim::num);
o.raw("r", json!(o.e("rule") + 1));
o.blend();
}
"st" => {
o.put("c", "color", &anim::arr);
o.put("o", "opacity", &anim::num);
stroke_fields(&mut o);
o.blend();
}
"gf" | "gs" => {
o.put("o", "opacity", &anim::num);
o.put("s", "start", &anim::arr2);
o.put("e", "end", &anim::arr2);
o.raw("t", json!(o.e("type") + 1));
o.put("h", "highlightLength", &anim::num);
o.put("a", "highlightAngle", &anim::num);
let ncolors = match g.get("colors").map(|p| &p.value) {
Some(Value::Gradient(gr)) => gr.colors.len(),
_ => 2,
};
if let Some(p) = g.get("colors") {
let k = export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors"));
o.raw("g", json!({"p": ncolors, "k": k}));
}
if ty == "gf" {
o.raw("r", json!(o.e("rule") + 1));
} else {
stroke_fields(&mut o);
}
o.blend();
}
"tm" => {
o.put("s", "start", &anim::num);
o.put("e", "end", &anim::num);
o.put("o", "offset", &anim::num);
o.raw("m", json!(o.e("mode") + 1));
}
"rp" => {
o.put("c", "copies", &anim::num);
o.put("o", "offset", &anim::num);
// Lottie: 1 = Above, 2 = Below; EffectCraft: 0 = Below, 1 = Above.
o.raw("m", json!(if o.e("composite") == 1 { 1 } else { 2 }));
if let Some(t) = g.sub("transform") {
let tr = shape_transform(o.ex, t, &lbl);
o.raw("tr", tr);
}
}
"rd" => o.put("r", "radius", &anim::num),
"op" => {
o.put("a", "amount", &anim::num);
o.raw("lj", json!(o.e("join") + 1));
o.raw("ml", json!(o.f("miter")));
if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 {
o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)"));
}
}
"pb" => o.put("a", "amount", &anim::num),
"tw" => {
o.put("a", "angle", &anim::num);
o.put("c", "center", &anim::arr2);
}
"zz" => {
o.put("s", "size", &anim::num);
o.put("r", "ridges", &anim::num);
o.raw("pt", json!({"a": 0, "k": o.e("points") + 1}));
}
"mm" => o.raw("mm", json!(o.e("mode") + 1)),
_ => {}
}
out.push(o.done());
}
out
}
// ---------------------------------------------------------------- import
fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option<Value>) {
if let (Some(j), Some(p)) = (j, g.get_mut(m)) {
import_prop(im, j, p, conv);
}
}
fn set_enum(g: &mut PropGroup, m: &str, v: u32) {
if let Some(p) = g.get_mut(m) {
p.value = Value::Enum(v);
}
}
fn int(j: &Json, k: &str) -> Option<i64> {
let v = j.get(k)?;
v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64)).or_else(|| v.get("k").and_then(anim::nums).and_then(|n| n.first().map(|f| f.round() as i64)))
}
fn import_blend(j: &Json, g: &mut PropGroup) {
if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
set_enum(g, "blend", blend_index(crate::blend_from_lottie(bm)));
}
}
fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
set(im, g, "width", j.get("w"), &anim::to_scalar);
if let Some(lc) = int(j, "lc") {
set_enum(g, "cap", (lc - 1).clamp(0, 2) as u32);
}
if let Some(lj) = int(j, "lj") {
set_enum(g, "join", (lj - 1).clamp(0, 2) as u32);
}
if let Some(p) = g.get_mut("miter") {
if let Some(ml) = j.get("ml").and_then(Json::as_f64) {
p.value = Value::Scalar(ml);
} else if let Some(ml2) = j.get("ml2") {
import_prop(im, ml2, p, &anim::to_scalar);
}
}
if let Some(list) = j.get("d").and_then(Json::as_array)
&& let Some(d) = g.sub_mut("dashes")
{
let mut seen_dash = false;
for e in list {
let m = match e.get("n").and_then(Json::as_str) {
Some("d") if !seen_dash => {
seen_dash = true;
"dash"
}
Some("g") => "gap",
Some("o") => "offset",
_ => continue,
};
set(im, d, m, e.get("v"), &anim::to_scalar);
}
}
}
fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) {
set(im, tr, "anchor", j.get("a"), &anim::to_vec2);
set(im, tr, "position", j.get("p"), &anim::to_vec2);
set(im, tr, "scale", j.get("s"), &anim::to_vec2);
set(im, tr, "rotation", j.get("r"), &anim::to_scalar);
set(im, tr, "opacity", j.get("o"), &anim::to_scalar);
set(im, tr, "skew", j.get("sk"), &anim::to_scalar);
set(im, tr, "skewAxis", j.get("sa"), &anim::to_scalar);
set(im, tr, "startOpacity", j.get("so"), &anim::to_scalar);
set(im, tr, "endOpacity", j.get("eo"), &anim::to_scalar);
}
/// Import Lottie shape items into shape contents groups.
pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<PropGroup> {
let mut out = vec![];
for j in items {
let ty = j.get("ty").and_then(Json::as_str).unwrap_or("");
let dir = |g: &mut PropGroup| {
if int(j, "d") == Some(3) {
set_enum(g, "direction", 1);
}
};
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);
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")
{
import_shape_transform(im, tr, t);
}
import_blend(j, &mut g);
g
}
"rc" => {
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], 0.0);
dir(&mut g);
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
g
}
"el" => {
let mut g = build::shape_ellipse(ids, [100.0, 100.0], [0.0, 0.0]);
dir(&mut g);
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
g
}
"sr" => {
let star = int(j, "sy").unwrap_or(1) != 2;
let mut g = build::shape_star(ids, star, 5.0, [0.0, 0.0], 100.0, 50.0);
dir(&mut g);
set(im, &mut g, "points", j.get("pt"), &anim::to_scalar);
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
set(im, &mut g, "rotation", j.get("r"), &anim::to_scalar);
set(im, &mut g, "innerRadius", j.get("ir"), &anim::to_scalar);
set(im, &mut g, "outerRadius", j.get("or"), &anim::to_scalar);
set(im, &mut g, "innerRoundness", j.get("is"), &anim::to_scalar);
set(im, &mut g, "outerRoundness", j.get("os"), &anim::to_scalar);
g
}
"sh" => {
let mut g = build::shape_path(ids, ShapePath::default());
dir(&mut g);
set(im, &mut g, "path", j.get("ks"), &to_path);
g
}
"fl" => {
let mut g = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
set(im, &mut g, "color", j.get("c"), &anim::to_color);
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
if int(j, "r") == Some(2) {
set_enum(&mut g, "rule", 1);
}
import_blend(j, &mut g);
g
}
"st" => {
let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
set(im, &mut g, "color", j.get("c"), &anim::to_color);
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
import_stroke(im, j, &mut g);
import_blend(j, &mut g);
g
}
"gf" | "gs" => {
let radial = int(j, "t") == Some(2);
let mut g = if ty == "gf" {
build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default())
} else {
build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0)
};
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
set(im, &mut g, "start", j.get("s"), &anim::to_vec2);
set(im, &mut g, "end", j.get("e"), &anim::to_vec2);
set(im, &mut g, "highlightLength", j.get("h"), &anim::to_scalar);
set(im, &mut g, "highlightAngle", j.get("a"), &anim::to_scalar);
if let Some(gj) = j.get("g") {
let n = gj.get("p").and_then(Json::as_u64).unwrap_or(2) as usize;
if let Some(k) = gj.get("k") {
set(im, &mut g, "colors", Some(k), &move |v| to_gradient(v, n));
}
}
if ty == "gf" {
if int(j, "r") == Some(2) {
set_enum(&mut g, "rule", 1);
}
} else {
import_stroke(im, j, &mut g);
}
import_blend(j, &mut g);
g
}
"tm" => {
let mut g = build::shape_trim(ids, 0.0, 100.0, 0.0);
set(im, &mut g, "start", j.get("s"), &anim::to_scalar);
set(im, &mut g, "end", j.get("e"), &anim::to_scalar);
set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
if int(j, "m") == Some(2) {
set_enum(&mut g, "mode", 1);
}
g
}
"rp" => {
let mut g = build::shape_repeater(ids, 3.0, [100.0, 0.0]);
set(im, &mut g, "copies", j.get("c"), &anim::to_scalar);
set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
set_enum(&mut g, "composite", if int(j, "m") == Some(1) { 1 } else { 0 });
if let (Some(tr), Some(t)) = (j.get("tr"), g.sub_mut("transform")) {
import_shape_transform(im, tr, t);
}
g
}
"rd" | "op" | "pb" | "tw" | "zz" | "mm" => {
let kind = match ty {
"rd" => "round",
"op" => "offset",
"pb" => "pucker",
"tw" => "twist",
"zz" => "zigzag",
_ => "merge",
};
let Some(mut g) = build::shape_simple_op(ids, kind) else { continue };
match ty {
"rd" => set(im, &mut g, "radius", j.get("r"), &anim::to_scalar),
"op" => {
set(im, &mut g, "amount", j.get("a"), &anim::to_scalar);
if let Some(lj) = int(j, "lj") {
set_enum(&mut g, "join", (lj - 1).clamp(0, 2) as u32);
}
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
p.value = Value::Scalar(ml);
}
}
"pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar),
"tw" => {
set(im, &mut g, "angle", j.get("a"), &anim::to_scalar);
set(im, &mut g, "center", j.get("c"), &anim::to_vec2);
}
"zz" => {
set(im, &mut g, "size", j.get("s"), &anim::to_scalar);
set(im, &mut g, "ridges", j.get("r"), &anim::to_scalar);
if int(j, "pt") == Some(2) {
set_enum(&mut g, "points", 1);
}
}
_ => {
if let Some(m) = int(j, "mm") {
set_enum(&mut g, "mode", (m - 1).clamp(0, 4) as u32);
}
}
}
g
}
"tr" => continue,
other => {
im.warn(format!("shape item `{other}` is not supported (skipped)"));
continue;
}
};
if let Some(nm) = j.get("nm").and_then(Json::as_str) {
g.name = nm.to_string();
}
if j.get("hd").and_then(Json::as_bool) == Some(true) {
g.enabled = false;
}
out.push(g);
}
out
}

700
crates/lottie/src/tests.rs Normal file
View File

@ -0,0 +1,700 @@
//! Round-trip tests: build comps feature by feature, export → import, compare property values
//! and rendered frames; plus easing math and document-level checks.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Ease, Gradient, Interp, Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
AlphaMode, Comp, Expression, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, MatteKind, Node, Project,
PropGroup, Solid, TrackMatte,
};
use effectcraft_render::render_frame;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value as Json, json};
use crate::{ExportOptions, export_comp, import};
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
fn setup() -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(200, 120, FrameRate::FPS_30, s(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn comp(p: &Project, cid: ItemId) -> Comp {
p.comp(cid).unwrap().clone()
}
fn push(p: &mut Project, cid: ItemId, l: Layer) -> LayerId {
let id = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
id
}
fn solid_layer(p: &mut Project, cid: ItemId, color: [f32; 3], w: u32, h: u32) -> Layer {
let c = comp(p, cid);
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(p, &c, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
}
fn shape_layer(p: &mut Project, cid: ItemId, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> Layer {
let c = comp(p, cid);
let mut l = build::layer(p, &c, "Shape Layer 1", LayerSource::Shape, (c.width, c.height), None);
let mut next = p.next_id;
let groups = items(&mut Ids(&mut next));
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.extend(groups.into_iter().map(Node::Group));
l
}
fn no_read(_: &str) -> Option<Vec<u8>> {
None
}
/// Export comp `cid`, serialise, import into a fresh project.
fn roundtrip(p: &Project, cid: ItemId, opts: &ExportOptions) -> (Project, ItemId, Vec<String>, Json) {
let res = export_comp(p, cid, opts, &no_read).unwrap();
let text = res.to_string_compact();
let mut q = Project::default();
let imp = import(&mut q, text.as_bytes(), "Imported", "", &mut |_| None).unwrap();
(q, imp.comp, res.warnings, res.json)
}
fn close(a: &Value, b: &Value, tol: f64) -> bool {
let near = |x: f64, y: f64| (x - y).abs() <= tol * (1.0 + x.abs().max(y.abs()) * 1e-3);
match (a, b) {
(Value::Path(p), Value::Path(q)) => {
let pts = |u: &[[f64; 2]], v: &[[f64; 2]]| u.len() == v.len() && u.iter().zip(v).all(|(m, n)| near(m[0], n[0]) && near(m[1], n[1]));
p.closed == q.closed && pts(&p.vertices, &q.vertices) && pts(&p.in_tangents, &q.in_tangents) && pts(&p.out_tangents, &q.out_tangents)
}
(Value::Gradient(g), Value::Gradient(h)) => {
g.colors.len() == h.colors.len()
&& g.colors.iter().zip(&h.colors).all(|(x, y)| near(x.0, y.0) && (0..3).all(|i| near(x.1[i] as f64, y.1[i] as f64)))
&& g.opacities.iter().zip(&h.opacities).all(|(x, y)| near(x.0, y.0) && near(x.1 as f64, y.1 as f64))
}
(Value::Text(x), Value::Text(y)) => x == y,
_ => {
let (ca, cb) = (a.components(), b.components());
ca.len() == cb.len() && ca.iter().zip(&cb).all(|(x, y)| near(*x, *y))
}
}
}
/// Compare every property of `a` with the same-path property of `b` at the sample times.
/// Returns the number of properties compared.
fn compare_tree(a: &PropGroup, b: &PropGroup, times: &[Tick], path: &str) -> usize {
let mut n = 0;
for (i, c) in a.children.iter().enumerate() {
let occ = a.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count();
let Some(other) = b.children.iter().filter(|o| o.match_id() == c.match_id()).nth(occ) else {
continue;
};
let here = format!("{path}/{}", c.match_id());
match (c, other) {
(Node::Prop(x), Node::Prop(y)) => {
for t in times {
let (va, vb) = (x.value_at(*t), y.value_at(*t));
assert!(close(&va, &vb, 1e-3), "{here} at {:.3}s: {va:?} != {vb:?}", t.seconds());
}
assert_eq!(x.expr.as_ref().map(|e| &e.text), y.expr.as_ref().map(|e| &e.text), "{here}: expression");
n += 1;
}
(Node::Group(x), Node::Group(y)) => {
if let (GroupKind::Mask { mode: m1, inverted: i1, .. }, GroupKind::Mask { mode: m2, inverted: i2, .. }) = (&x.kind, &y.kind) {
assert_eq!((m1, i1), (m2, i2), "{here}: mask mode");
}
assert_eq!(x.enabled, y.enabled, "{here}: enabled");
n += compare_tree(x, y, times, &here);
}
_ => panic!("{here}: node kind differs"),
}
}
n
}
fn sample_times(c: &Comp) -> Vec<Tick> {
(0..=12).map(|i| Tick(c.duration.0 * i / 12)).collect()
}
/// Compare layers (timing, switches, mattes, parents, properties).
fn compare_comps(p: &Project, a: ItemId, q: &Project, b: ItemId) -> usize {
let (ca, cb) = (p.comp(a).unwrap(), q.comp(b).unwrap());
assert_eq!((ca.width, ca.height, ca.frame_rate, ca.duration), (cb.width, cb.height, cb.frame_rate, cb.duration));
assert_eq!(ca.layers.len(), cb.layers.len(), "layer count");
let times = sample_times(ca);
let mut n = 0;
for (la, lb) in ca.layers.iter().zip(&cb.layers) {
assert_eq!(la.name, lb.name);
assert_eq!(la.source.type_name(), lb.source.type_name(), "{}", la.name);
assert_eq!((la.in_point, la.out_point, la.start_time), (lb.in_point, lb.out_point, lb.start_time), "{}: timing", la.name);
assert!((la.stretch - lb.stretch).abs() < 1e-9, "{}: stretch", la.name);
assert_eq!(la.blend_mode, lb.blend_mode, "{}: blend", la.name);
assert_eq!(la.parent.and_then(|x| ca.index_of(x)), lb.parent.and_then(|x| cb.index_of(x)), "{}: parent", la.name);
assert_eq!(la.track_matte.map(|m| (ca.index_of(m.layer), m.kind)), lb.track_matte.map(|m| (cb.index_of(m.layer), m.kind)), "{}: matte", la.name);
n += compare_tree(&la.props, &lb.props, &times, &la.name);
}
n
}
/// Rendered frames must agree within a small tolerance.
fn compare_renders(p: &Project, a: ItemId, q: &Project, b: ItemId, tol: f32) {
let c = p.comp(a).unwrap();
assert!(sample_times(c).into_iter().any(|t| not_blank(p, a, t)), "nothing rendered");
for t in sample_times(c).into_iter().step_by(3) {
let (x, y) = (render_frame(p, a, t, 0.5), render_frame(q, b, t, 0.5));
assert_eq!((x.width, x.height), (y.width, y.height));
let mut worst = 0.0f32;
let mut bad = 0usize;
for (u, v) in x.data.iter().zip(&y.data) {
let d = (0..4).map(|i| (u[i] - v[i]).abs()).fold(0.0, f32::max);
worst = worst.max(d);
if d > tol {
bad += 1;
}
}
assert!(bad * 200 <= x.data.len(), "at {:.2}s: {bad} pixels differ by more than {tol} (worst {worst})", t.seconds());
}
}
fn not_blank(p: &Project, cid: ItemId, t: Tick) -> bool {
render_frame(p, cid, t, 0.5).data.iter().any(|px| px[3] > 0.5)
}
// ---------------------------------------------------------------- easing math
#[test]
fn easing_converts_exactly_both_ways() {
let (mut p, cid) = setup();
let mut l = solid_layer(&mut p, cid, [1.0, 0.5, 0.0], 40, 40);
let tr = l.props.sub_mut("transform").unwrap();
// Custom speed / influence (non-spatial), easy ease, hold, auto-bezier and linear sides.
let rot = tr.get_mut("rotation").unwrap();
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
a.out_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: 300.0, influence: 0.6 }];
let mut b = Keyframe::new(s(0.8), Value::Scalar(180.0));
b.in_interp = Interp::Bezier;
b.in_ease = vec![Ease { speed: -50.0, influence: 0.2 }];
b.out_interp = Interp::Hold;
let c = Keyframe::new(s(1.2), Value::Scalar(90.0)).eased();
let mut d = Keyframe::new(s(1.6), Value::Scalar(45.0));
d.auto_bezier = true;
d.in_interp = Interp::Bezier;
d.out_interp = Interp::Bezier;
let e = Keyframe::new(s(1.9), Value::Scalar(0.0));
rot.keys = vec![a, b, c, d, e];
let sc = tr.get_mut("scale").unwrap();
sc.keys = vec![Keyframe::new(s(0.0), Value::Vec3([100.0, 50.0, 100.0])).eased(), Keyframe::new(s(1.0), Value::Vec3([20.0, 150.0, 100.0])).eased()];
sc.keys[0].out_ease = vec![Ease { speed: 10.0, influence: 0.9 }, Ease { speed: 0.0, influence: 0.1 }, Ease::EASY];
push(&mut p, cid, l);
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
// The Lottie tangents of the first rotation segment: o = (0.6, 300·0.6·0.8/180).
let k0 = &json["layers"][0]["ks"]["r"]["k"][0];
assert!((k0["o"]["x"][0].as_f64().unwrap() - 0.6).abs() < 1e-9);
assert!((k0["o"]["y"][0].as_f64().unwrap() - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5);
assert_eq!(json["layers"][0]["ks"]["r"]["k"][1]["h"], json!(1));
let rq = &q.comp(nc).unwrap().layers[0];
let rk = &rq.props.prop("transform/rotation").unwrap().keys;
assert!((rk[0].out_ease[0].speed - 300.0).abs() < 1e-3 && (rk[0].out_ease[0].influence - 0.6).abs() < 1e-6, "{:?}", rk[0].out_ease);
assert!((rk[1].in_ease[0].speed + 50.0).abs() < 1e-3);
assert_eq!(rk[1].out_interp, Interp::Hold);
assert!(compare_comps(&p, cid, &q, nc) > 5);
}
#[test]
fn spatial_tangents_and_separated_dimensions() {
let (mut p, cid) = setup();
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
let pos = l.props.prop_mut("transform/position").unwrap();
let mut k1 = Keyframe::new(s(0.0), Value::Vec3([20.0, 20.0, 0.0])).eased();
k1.spatial_auto = false;
k1.spatial_out = [80.0, 0.0, 0.0];
let k2 = Keyframe::new(s(1.0), Value::Vec3([180.0, 100.0, 0.0])).eased();
let k3 = Keyframe::new(s(1.5), Value::Vec3([100.0, 60.0, 0.0]));
pos.keys = vec![k1, k2, k3];
push(&mut p, cid, l);
// A second layer with separated X/Y position.
let mut l2 = solid_layer(&mut p, cid, [0.0, 0.0, 1.0], 20, 20);
let mut next = p.next_id;
let tr = l2.props.sub_mut("transform").unwrap();
for (m, name, keys) in [
("positionY", "Y Position", vec![Keyframe::new(s(0.0), Value::Scalar(10.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(110.0))]),
("positionX", "X Position", vec![Keyframe::new(s(0.5), Value::Scalar(30.0)), Keyframe::new(s(1.5), Value::Scalar(170.0)).eased()]),
] {
let mut pr = effectcraft_project::Property::new(next, m, name, Value::Scalar(0.0));
next += 1;
pr.keys = keys;
tr.children.insert(2, Node::Prop(pr));
}
let mut pz = effectcraft_project::Property::new(next, "positionZ", "Z Position", Value::Scalar(0.0));
pz.three_d_only = true;
next += 1;
tr.children.insert(4, Node::Prop(pz));
p.next_id = next;
push(&mut p, cid, l2);
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(json["layers"][1]["ks"]["p"]["s"], json!(true));
let to = &json["layers"][0]["ks"]["p"]["k"][0]["to"];
assert_eq!(to[0].as_f64(), Some(80.0));
assert!(compare_comps(&p, cid, &q, nc) > 10);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
// ---------------------------------------------------------------- shapes
#[test]
fn shape_contents_roundtrip() {
let (mut p, cid) = setup();
let l = shape_layer(&mut p, cid, |ids| {
let mut rect = build::shape_rect(ids, [60.0, 40.0], [0.0, 0.0], 6.0);
rect.get_mut("size").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 40.0])).eased(), Keyframe::new(s(1.5), Value::Vec2([90.0, 20.0]))];
let ell = build::shape_ellipse(ids, [30.0, 30.0], [20.0, 10.0]);
let mut stroke = build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 3.0);
{
let d = stroke.sub_mut("dashes").unwrap();
d.get_mut("dash").unwrap().value = Value::Scalar(6.0);
d.get_mut("gap").unwrap().value = Value::Scalar(3.0);
}
stroke.get_mut("cap").unwrap().value = Value::Enum(1);
stroke.get_mut("join").unwrap().value = Value::Enum(2);
let mut fill = build::shape_fill(ids, [1.0, 0.2, 0.2, 1.0]);
fill.get_mut("color").unwrap().keys =
vec![Keyframe::new(s(0.0), Value::Color([1.0, 0.2, 0.2, 1.0])).hold(), Keyframe::new(s(1.0), Value::Color([0.2, 1.0, 0.2, 1.0])).hold()];
fill.get_mut("rule").unwrap().value = Value::Enum(1);
let mut g = build::shape_group(ids, "Group A", vec![rect, ell, stroke, fill]);
let tr = g.sub_mut("transform").unwrap();
tr.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 60.0])), Keyframe::new(s(2.0), Value::Vec2([90.0, 50.0]))];
tr.get_mut("rotation").unwrap().value = Value::Scalar(15.0);
tr.get_mut("skew").unwrap().value = Value::Scalar(10.0);
g.get_mut("blend").unwrap().value = Value::Enum(BlendMode::ALL.iter().position(|b| *b == BlendMode::Screen).unwrap() as u32);
let star = build::shape_star(ids, true, 5.0, [150.0, 40.0], 30.0, 12.0);
let poly = build::shape_star(ids, false, 6.0, [150.0, 95.0], 20.0, 0.0);
let path = build::shape_path(
ids,
ShapePath {
vertices: vec![[10.0, 110.0], [40.0, 80.0], [70.0, 110.0]],
in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]],
out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]],
closed: true,
},
);
let grad = Gradient {
colors: vec![(0.0, [1.0, 1.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 1.0, 1.0])],
opacities: vec![(0.0, 1.0), (1.0, 0.5)],
};
let gfill = build::shape_gradient_fill(ids, true, [150.0, 40.0], [180.0, 40.0], grad.clone());
let gstroke = build::shape_gradient_stroke(ids, false, [130.0, 95.0], [170.0, 95.0], grad, 4.0);
let mut g2 = build::shape_group(ids, "Group B", vec![star, gfill, poly, gstroke]);
g2.enabled = true;
let mut fill2 = build::shape_fill(ids, [0.9, 0.9, 0.1, 1.0]);
fill2.get_mut("opacity").unwrap().value = Value::Scalar(70.0);
let g3 = build::shape_group(ids, "Group C", vec![path, fill2]);
vec![g, g2, g3]
});
push(&mut p, cid, l);
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
assert!(warnings.is_empty(), "{warnings:?}");
assert!(compare_comps(&p, cid, &q, nc) > 60);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
#[test]
fn path_operators_roundtrip() {
let (mut p, cid) = setup();
let l = shape_layer(&mut p, cid, |ids| {
let mut out = vec![];
for (k, (x, y)) in ["round", "offset", "pucker", "twist", "zigzag", "merge"].iter().zip([
(30.0, 30.0),
(90.0, 30.0),
(150.0, 30.0),
(30.0, 90.0),
(90.0, 90.0),
(150.0, 90.0),
]) {
let rect = build::shape_rect(ids, [36.0, 30.0], [x, y], 0.0);
let ell = build::shape_ellipse(ids, [20.0, 20.0], [x + 10.0, y]);
let mut op = build::shape_simple_op(ids, k).unwrap();
match *k {
"pucker" => op.get_mut("amount").unwrap().value = Value::Scalar(-30.0),
"twist" => op.get_mut("angle").unwrap().value = Value::Scalar(40.0),
"zigzag" => op.get_mut("points").unwrap().value = Value::Enum(1),
"merge" => op.get_mut("mode").unwrap().value = Value::Enum(2),
"offset" => op.get_mut("amount").unwrap().value = Value::Scalar(-4.0),
_ => {}
}
let fill = build::shape_fill(ids, [0.2, 0.6, 1.0, 1.0]);
out.push(build::shape_group(ids, k, vec![rect, ell, op, fill]));
}
// Trim paths (animated) and a repeater.
let ell = build::shape_ellipse(ids, [50.0, 50.0], [100.0, 60.0]);
let mut trim = build::shape_trim(ids, 0.0, 100.0, 0.0);
trim.get_mut("end").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.5), Value::Scalar(100.0)).eased()];
trim.get_mut("mode").unwrap().value = Value::Enum(1);
let st = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 4.0);
out.push(build::shape_group(ids, "Trim", vec![ell, trim, st]));
let r = build::shape_rect(ids, [8.0, 8.0], [10.0, 10.0], 0.0);
let f = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
let mut rep = build::shape_repeater(ids, 4.0, [12.0, 0.0]);
rep.get_mut("composite").unwrap().value = Value::Enum(1);
let rt = rep.sub_mut("transform").unwrap();
rt.get_mut("rotation").unwrap().value = Value::Scalar(10.0);
rt.get_mut("endOpacity").unwrap().value = Value::Scalar(30.0);
out.push(build::shape_group(ids, "Repeat", vec![r, f, rep]));
out
});
push(&mut p, cid, l);
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
assert!(warnings.is_empty(), "{warnings:?}");
assert!(compare_comps(&p, cid, &q, nc) > 50);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
// ---------------------------------------------------------------- layers
#[test]
fn layers_parenting_timing_mattes_masks_and_blend_modes() {
let (mut p, cid) = setup();
let c = comp(&p, cid);
// Null parent with rotation; child solid.
let mut null = build::layer(&mut p, &c, "Null 1", LayerSource::Null, (100, 100), None);
null.props.prop_mut("transform/rotation").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(90.0))];
let null_id = null.id;
let mut child = solid_layer(&mut p, cid, [1.0, 1.0, 0.0], 40, 20);
child.parent = Some(null_id);
child.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 0.0, 0.0]);
child.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([150.0, 80.0, 100.0]);
child.in_point = s(0.2);
child.out_point = s(1.8);
child.start_time = s(0.1);
child.stretch = 200.0;
child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(20.0)), Keyframe::new(s(0.8), Value::Scalar(100.0))];
// Matte (ellipse shape) over a solid with masks and a blend mode.
let matte = shape_layer(&mut p, cid, |ids| {
let e = build::shape_ellipse(ids, [120.0, 80.0], [100.0, 60.0]);
let f = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
vec![build::shape_group(ids, "Ellipse", vec![e, f])]
});
let mut matte = matte;
matte.name = "Matte".into();
matte.switches.video = false;
let matte_id = matte.id;
let mut masked = solid_layer(&mut p, cid, [0.2, 0.4, 1.0], 200, 120);
masked.name = "Masked".into();
masked.blend_mode = BlendMode::Multiply;
masked.track_matte = Some(TrackMatte { layer: matte_id, kind: MatteKind::Alpha });
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let mut m1 = build::mask(&mut ids, "Mask 1", ShapePath::rect([100.0, 60.0], 150.0, 90.0), MaskMode::Add, [255, 0, 0]);
m1.get_mut("expansion").unwrap().value = Value::Scalar(-5.0);
m1.get_mut("path").unwrap().keys = vec![
Keyframe::new(s(0.0), Value::Path(ShapePath::rect([100.0, 60.0], 150.0, 90.0))),
Keyframe::new(s(2.0), Value::Path(ShapePath::rect([90.0, 60.0], 120.0, 80.0))),
];
let mut m2 = build::mask(&mut ids, "Mask 2", ShapePath::ellipse([100.0, 60.0], 40.0, 40.0), MaskMode::Subtract, [0, 255, 0]);
m2.get_mut("opacity").unwrap().value = Value::Scalar(60.0);
let mut m3 = build::mask(&mut ids, "Mask 3", ShapePath::rect([30.0, 30.0], 20.0, 20.0), MaskMode::Add, [0, 0, 255]);
if let GroupKind::Mask { inverted, .. } = &mut m3.kind {
*inverted = true;
}
m3.get_mut("feather").unwrap().value = Value::Vec2([4.0, 4.0]);
let masks = masked.props.sub_mut("masks").unwrap();
masks.children.extend([m1, m2, m3].map(Node::Group));
}
p.next_id = next;
// A luma-inverted matte pair below.
let mut luma = solid_layer(&mut p, cid, [0.5, 0.5, 0.5], 200, 120);
luma.name = "Luma".into();
luma.switches.video = false;
let luma_id = luma.id;
let mut bg = solid_layer(&mut p, cid, [0.9, 0.1, 0.1], 200, 120);
bg.name = "Bg".into();
bg.track_matte = Some(TrackMatte { layer: luma_id, kind: MatteKind::LumaInverted });
for l in [null, child, matte, masked, luma, bg] {
push(&mut p, cid, l);
}
p.comp_mut(cid).unwrap().markers.push(Marker { time: s(1.0), duration: s(0.5), comment: "beat".into(), ..Default::default() });
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("feather")), "{warnings:?}");
assert_eq!(json["layers"][2]["td"], json!(1));
assert_eq!(json["layers"][3]["tt"], json!(1));
assert_eq!(json["layers"][3]["masksProperties"][1]["mode"], json!("s"));
assert_eq!(json["layers"][5]["tt"], json!(4));
assert_eq!(json["markers"][0]["cm"], json!("beat"));
assert_eq!(q.comp(nc).unwrap().markers[0].time, s(1.0));
assert!(compare_comps(&p, cid, &q, nc) > 20);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
#[test]
fn precomp_with_time_remap() {
let (mut p, cid) = setup();
let inner = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(Comp::new(100, 100, FrameRate::FPS_30, s(2.0)).into()));
let sl = shape_layer(&mut p, inner, |ids| {
let mut r = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
r.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([-20.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([60.0, 0.0]))];
let f = build::shape_fill(ids, [0.0, 1.0, 0.5, 1.0]);
vec![build::shape_group(ids, "Box", vec![r, f])]
});
push(&mut p, inner, sl);
let c = comp(&p, cid);
let mut pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, (100, 100), None);
let mut next = p.next_id;
let mut tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(0.0));
tr.keys = vec![Keyframe::new(s(0.0), Value::Scalar(1.5)), Keyframe::new(s(2.0), Value::Scalar(0.0))];
p.next_id = next;
pre.props.children.insert(0, Node::Prop(tr));
push(&mut p, cid, pre);
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(json["assets"].as_array().unwrap().len(), 1);
assert!(json["layers"][0]["tm"].is_object());
assert!(compare_comps(&p, cid, &q, nc) > 5);
let qi = match &q.comp(nc).unwrap().layers[0].source {
LayerSource::Comp { item } => *item,
_ => panic!("not a precomp"),
};
assert!(compare_comps(&p, inner, &q, qi) > 3);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
#[test]
fn text_layers_roundtrip_as_text_and_as_shapes() {
let (mut p, cid) = setup();
let c = comp(&p, cid);
let mut l = build::layer(&mut p, &c, "Title", LayerSource::Text, (c.width, c.height), None);
let doc = TextDoc { text: "Hi\nthere".into(), size: 30.0, fill: [1.0, 0.8, 0.1, 1.0], tracking: 20.0, ..Default::default() };
let doc2 = TextDoc { text: "Bye".into(), ..doc.clone() };
let st = l.props.prop_mut("text/sourceText").unwrap();
st.value = Value::Text(Box::new(doc.clone()));
st.keys = vec![Keyframe::new(s(0.0), Value::Text(Box::new(doc))).hold(), Keyframe::new(s(1.5), Value::Text(Box::new(doc2))).hold()];
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 50.0, 0.0]);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let mut props = build::text_anim_props(&mut ids, "opacity", false);
props.extend(build::text_anim_props(&mut ids, "position", false));
let mut a = build::text_animator(&mut ids, "Animator 1", props);
let pr = a.sub_mut("properties").unwrap();
pr.get_mut("opacity").unwrap().value = Value::Scalar(0.0);
pr.get_mut("position").unwrap().value = Value::Vec3([0.0, 20.0, 0.0]);
let sel = a.sub_mut("selectors").unwrap().children[0].as_group_mut().unwrap();
sel.get_mut("start").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0))];
sel.sub_mut("advanced").unwrap().get_mut("shape").unwrap().value = Value::Enum(1);
l.props.group_mut("text/animators").unwrap().children.push(Node::Group(a));
}
p.next_id = next;
push(&mut p, cid, l);
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(json["layers"][0]["ty"], json!(5));
assert_eq!(json["layers"][0]["t"]["d"]["k"][0]["s"]["t"], json!("Hi\rthere"));
assert_eq!(json["fonts"]["list"][0]["fFamily"], json!("Inter"));
assert!(compare_comps(&p, cid, &q, nc) > 10);
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
// As glyph shapes: the static look at time 0 survives (animators baked out).
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.clear();
let opts = ExportOptions { text_as_shapes: true, ..Default::default() };
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
assert_eq!(res.json["layers"][0]["ty"], json!(4));
let mut q2 = Project::default();
let imp = import(&mut q2, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
let (a, b) = (render_frame(&p, cid, s(0.5), 1.0), render_frame(&q2, imp.comp, s(0.5), 1.0));
let (ca, cb) = (a.data.iter().map(|px| px[3] as f64).sum::<f64>(), b.data.iter().map(|px| px[3] as f64).sum::<f64>());
assert!(ca > 50.0 && (ca - cb).abs() / ca < 0.05, "glyph coverage {ca} vs {cb}");
}
#[test]
fn effects_expressions_and_warnings() {
let (mut p, cid) = setup();
let mut l = solid_layer(&mut p, cid, [1.0, 1.0, 1.0], 80, 60);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let fx = l.props.sub_mut("effects").unwrap();
for id in ["ec.blur.gaussian", "ec.color.tint", "ec.perspective.dropshadow", "ec.control.slider", "ec.distort.twirl"] {
if let Some(spec) = effectcraft_effects::find(id) {
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [80.0, 60.0]);
if let Some(b) = g.get_mut("blurriness") {
b.keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(8.0))];
}
if let Some(o) = g.get_mut("opacity") {
o.value = Value::Scalar(40.0);
}
if let Some(a) = g.get_mut("amount") {
a.value = Value::Scalar(60.0);
}
if let Some(sl) = g.get_mut("slider") {
sl.value = Value::Scalar(12.5);
}
fx.children.push(Node::Group(g));
}
}
}
p.next_id = next;
l.props.prop_mut("transform/rotation").unwrap().expr = Some(Expression { text: "time * 90".into(), enabled: true });
push(&mut p, cid, l);
let c = comp(&p, cid);
let cam = build::layer(&mut p, &c, "Camera 1", LayerSource::Camera, (c.width, c.height), None);
push(&mut p, cid, cam);
// Without the expression flag: warned and dropped.
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
assert!(res.json["layers"][0]["ks"]["r"].get("x").is_none());
let w = res.warnings.join("\n");
assert!(w.contains("expression not exported"), "{w}");
assert!(w.contains("effect not supported by Lottie"), "{w}");
assert!(w.contains("cameras and lights"), "{w}");
// With it: `x` strings, and supported effects survive.
let opts = ExportOptions { include_expressions: true, ..Default::default() };
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
assert_eq!(res.json["layers"][0]["ks"]["r"]["x"], json!("time * 90"));
let ef = res.json["layers"][0]["ef"].as_array().unwrap();
assert_eq!(ef.iter().map(|e| e["ty"].as_u64().unwrap()).collect::<Vec<_>>(), vec![29, 20, 25, 5]);
assert!((ef[2]["ef"][1]["v"]["k"].as_f64().unwrap() - 40.0 * 2.55).abs() < 1e-9);
let mut q = Project::default();
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
let lq = &q.comp(imp.comp).unwrap().layers[0];
let fxq: Vec<&PropGroup> = lq.effects().unwrap().groups().collect();
assert_eq!(fxq.len(), 4);
assert!((fxq[2].get("opacity").unwrap().value.as_f64() - 40.0).abs() < 1e-9);
assert_eq!(fxq[3].get("slider").unwrap().value, Value::Scalar(12.5));
assert_eq!(lq.props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90");
let lp = &p.comp(cid).unwrap().layers[0];
let times = sample_times(p.comp(cid).unwrap());
compare_tree(lp.effects().unwrap().groups().next().unwrap(), fxq[0], &times, "blur");
}
#[test]
fn images_embed_as_base64_and_come_back() {
let (mut p, cid) = setup();
let png: Vec<u8> = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5];
let fid = p.add_item(
"pic.png",
Label::Lavender,
None,
ItemKind::Footage(Footage {
path: "/virtual/pic.png".into(),
kind: FootageKind::Still,
width: 64,
height: 32,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
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 c = comp(&p, cid);
let l = build::layer(&mut p, &c, "pic.png", LayerSource::Footage { item: fid }, (64, 32), None);
push(&mut p, cid, l);
let bytes = png.clone();
let read = move |path: &str| (path == "/virtual/pic.png").then(|| bytes.clone());
let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap();
let asset = &res.json["assets"][0];
assert!(asset["p"].as_str().unwrap().starts_with("data:image/png;base64,"));
assert_eq!(res.json["layers"][0]["ty"], json!(2));
let mut q = Project::default();
let mut stored = vec![];
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |img| {
stored.push(img.bytes.clone());
Some(format!("/tmp/{}.{}", img.id, img.ext))
})
.unwrap();
assert_eq!(stored, vec![png]);
let l = &q.comp(imp.comp).unwrap().layers[0];
let LayerSource::Footage { item } = l.source else { panic!("not footage") };
let ItemKind::Footage(f) = &q.item(item).unwrap().kind else { panic!() };
assert_eq!((f.width, f.height), (64, 32));
assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path);
}
#[test]
fn dotlottie_archive_roundtrip() {
let (mut p, cid) = setup();
let l = solid_layer(&mut p, cid, [1.0, 0.0, 0.0], 50, 50);
push(&mut p, cid, l);
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
let zip = crate::to_dotlottie(&res, "Main");
assert!(zip.starts_with(b"PK"));
let mut q = Project::default();
let imp = import(&mut q, &zip, "x", "", &mut |_| None).unwrap();
assert_eq!(q.comp(imp.comp).unwrap().layers.len(), 1);
}
// ---------------------------------------------------------------- hand-written fixtures
fn fixture(name: &str) -> (Project, ItemId, Vec<String>) {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let bytes = std::fs::read(&path).unwrap();
let mut q = Project::default();
let imp = import(&mut q, &bytes, name, "", &mut |_| None).unwrap_or_else(|e| panic!("{name}: {e}"));
(q, imp.comp, imp.warnings)
}
#[test]
fn fixture_bouncing_ball() {
let (q, c, w) = fixture("bouncing-ball.json");
assert!(w.is_empty(), "{w:?}");
let comp = q.comp(c).unwrap();
assert_eq!((comp.width, comp.height, comp.frame_rate), (200, 200, FrameRate::FPS_30));
let l = &comp.layers[0];
let pos = l.props.prop("transform/position").unwrap();
assert_eq!(pos.keys.len(), 3);
// Eased: slow at the top, the key values are hit exactly.
assert_eq!(pos.value_at(s(0.5)).as_vec3()[1], 160.0);
assert!(not_blank(&q, c, s(0.25)));
}
#[test]
fn fixture_shapes_and_gradients() {
let (q, c, w) = fixture("shapes-gradient.json");
assert!(w.is_empty(), "{w:?}");
let l = &q.comp(c).unwrap().layers[0];
let contents = l.props.sub("contents").unwrap();
let g = contents.groups().next().unwrap();
let kinds: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
assert_eq!(kinds, ["star", "rect", "gfill", "stroke", "trim"]);
assert!(not_blank(&q, c, s(0.5)));
}
#[test]
fn fixture_precomp_matte_text() {
let (q, c, w) = fixture("precomp-matte-text.json");
assert!(w.is_empty(), "{w:?}");
let comp = q.comp(c).unwrap();
assert_eq!(comp.layers.len(), 4);
let matte = &comp.layers[0];
assert!(!matte.switches.video);
assert_eq!(comp.layers[1].track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Alpha)));
assert!(matches!(comp.layers[1].source, LayerSource::Comp { .. }));
assert_eq!(comp.layers[2].parent, Some(comp.layers[3].id));
let Value::Text(doc) = &comp.layers[2].props.prop("text/sourceText").unwrap().value else { panic!() };
assert_eq!(doc.text, "Hello\nLottie");
assert_eq!(doc.justify, effectcraft_keyframe::Justify::Center);
assert!(not_blank(&q, c, s(0.5)));
}
#[test]
fn fixture_legacy_keyframes_and_masks() {
let (q, c, w) = fixture("legacy-masks.json");
assert!(w.iter().all(|w| w.contains("not supported")), "{w:?}");
let comp = q.comp(c).unwrap();
let l = &comp.layers[0];
// Legacy `e` end values fill the last key.
let o = l.props.prop("transform/opacity").unwrap();
assert_eq!(o.keys.len(), 2);
assert_eq!(o.keys[1].value, Value::Scalar(100.0));
let masks: Vec<_> = l.masks().unwrap().groups().collect();
assert_eq!(masks.len(), 2);
assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. }));
assert!(not_blank(&q, c, s(0.9)));
}

369
crates/lottie/src/text.rs Normal file
View File

@ -0,0 +1,369 @@
//! Text layers ↔ Lottie text data (`t`: document keys `d`, animators `a`, more options `m`),
//! and text converted to glyph shapes.
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_time::Tick;
use serde_json::{Map, Value as Json, json};
use crate::anim::{self, Ex, Im, export_prop, import_prop};
use crate::shapes;
const JUSTIFY: [Justify; 7] =
[Justify::Left, Justify::Right, Justify::Center, Justify::JustifyLastLeft, Justify::JustifyLastRight, Justify::JustifyLastCenter, Justify::JustifyAll];
/// Font key (`fName`) of a family/style.
pub(crate) fn font_name(family: &str, style: &str) -> String {
format!("{}-{}", family.replace(' ', ""), style.replace(' ', ""))
}
fn rgb(c: [f32; 4]) -> Json {
json!([c[0] as f64, c[1] as f64, c[2] as f64])
}
fn doc_json(d: &TextDoc) -> Json {
let mut o = json!({
"t": d.text.replace("\r\n", "\r").replace('\n', "\r"),
"f": font_name(&d.font, &d.style),
"s": d.size,
"j": JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0),
"tr": d.tracking,
"lh": d.leading.unwrap_or(d.size * 1.2),
"ls": d.baseline_shift,
"of": d.stroke_over_fill,
});
if d.apply_fill {
o["fc"] = rgb(d.fill);
}
if d.apply_stroke && d.stroke_width > 0.0 {
o["sc"] = rgb(d.stroke);
o["sw"] = json!(d.stroke_width);
}
if d.all_caps {
o["ca"] = json!(1);
} else if d.small_caps {
o["ca"] = json!(2);
}
if let Some(b) = d.box_size {
o["sz"] = json!(b);
o["ps"] = json!(d.box_pos);
}
o
}
fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc {
let mut d = TextDoc::default();
let f = |k: &str| j.get(k).and_then(Json::as_f64);
if let Some(t) = j.get("t").and_then(Json::as_str) {
d.text = t.replace(['\r', '\u{3}'], "\n");
}
if let Some(name) = j.get("f").and_then(Json::as_str) {
match fonts.iter().find(|(n, _, _)| n == name) {
Some((_, fam, st)) => {
d.font = fam.clone();
d.style = st.clone();
}
None => {
let (fam, st) = name.split_once('-').unwrap_or((name, "Regular"));
d.font = fam.to_string();
d.style = st.to_string();
}
}
}
if let Some(s) = f("s") {
d.size = s;
}
let col = |k: &str| j.get(k).and_then(anim::to_color).map(|v| v.as_color());
match col("fc") {
Some(c) => d.fill = c,
None => d.apply_fill = false,
}
if let Some(c) = col("sc") {
d.stroke = c;
d.apply_stroke = true;
d.stroke_width = f("sw").unwrap_or(1.0);
}
if let Some(of) = j.get("of").and_then(Json::as_bool) {
d.stroke_over_fill = of;
}
if let Some(i) = j.get("j").and_then(Json::as_u64) {
d.justify = JUSTIFY.get(i as usize).copied().unwrap_or_default();
}
d.tracking = f("tr").unwrap_or(0.0);
if let Some(lh) = f("lh")
&& (lh - d.size * 1.2).abs() > 1e-6
{
d.leading = Some(lh);
}
d.baseline_shift = f("ls").unwrap_or(0.0);
match j.get("ca").and_then(Json::as_u64) {
Some(1) => d.all_caps = true,
Some(2) => d.small_caps = true,
_ => {}
}
if let Some(sz) = j.get("sz").and_then(anim::nums).filter(|v| v.len() >= 2) {
d.box_size = Some([sz[0], sz[1]]);
if let Some(ps) = j.get("ps").and_then(anim::nums).filter(|v| v.len() >= 2) {
d.box_pos = [ps[0], ps[1]];
}
}
d
}
/// Lottie text animator property key → (animator kind, match id).
const ANIM_PROPS: &[(&str, &str, &str)] = &[
("a", "anchor", "anchor"),
("p", "position", "position"),
("s", "scale", "scale"),
("sk", "skew", "skew"),
("sa", "skew", "skewAxis"),
("r", "rotation", "rotation"),
("o", "opacity", "opacity"),
("fc", "fillColor", "fillColor"),
("sc", "strokeColor", "strokeColor"),
("sw", "strokeWidth", "strokeWidth"),
("fh", "fillHue", "fillHue"),
("fs", "fillSaturation", "fillSaturation"),
("fb", "fillBrightness", "fillBrightness"),
("t", "tracking", "tracking"),
];
fn conv_for(v: &Value) -> fn(&Value) -> Json {
match v {
Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => anim::arr,
_ => anim::num,
}
}
/// Export a text layer's `t` object. Fonts used are added to `fonts`.
pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) -> Json {
let Some(tg) = layer.props.sub("text") else { return json!({}) };
let label = &layer.name;
let mut docs = vec![];
let mut add_font = |d: &TextDoc| {
let n = font_name(&d.font, &d.style);
if !fonts.iter().any(|f| f["fName"] == json!(n)) {
fonts.push(json!({"fName": n, "fFamily": d.font, "fStyle": d.style, "ascent": 75}));
}
};
if let Some(st) = tg.get("sourceText") {
if st.keys.is_empty() {
if let Value::Text(d) = &st.value {
add_font(d);
docs.push(json!({"t": 0, "s": doc_json(d)}));
}
} else {
for k in &st.keys {
if let Value::Text(d) = &k.value {
add_font(d);
docs.push(json!({"t": ex.tb.frame(k.time), "s": doc_json(d)}));
}
}
}
if st.has_expression() {
ex.warn(format!("{label} ▸ Source Text: expressions on Source Text are not exported"));
}
if let Value::Text(d) = &st.value
&& ((d.h_scale - 100.0).abs() > 1e-9 || (d.v_scale - 100.0).abs() > 1e-9 || d.faux_bold || d.faux_italic)
{
ex.warn(format!("{label}: horizontal/vertical scale and faux styles are not part of Lottie text"));
}
}
let mut animators = vec![];
if let Some(list) = tg.sub("animators") {
for a in list.groups() {
animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name)));
}
}
let mut m = json!({"g": 1, "a": {"a": 0, "k": [0, 0]}});
if let Some(more) = tg.sub("moreOptions") {
m["g"] = json!(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0) + 1);
if let Some(p) = more.get("groupingAlignment") {
m["a"] = export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment"));
}
}
if tg.sub("pathOptions").and_then(|p| p.get("path")).is_some_and(|p| p.value.as_enum() > 0) {
ex.warn(format!("{label}: text on a path is not exported"));
}
json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}})
}
fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json {
let mut props = Map::new();
if let Some(pg) = a.sub("properties") {
for p in pg.props() {
match ANIM_PROPS.iter().find(|(_, _, m)| *m == p.match_id) {
Some((key, _, _)) => {
let v = export_prop(ex, p, &conv_for(&p.value), &format!("{label} ▸ {}", p.name));
props.insert((*key).into(), v);
}
None if matches!(p.match_id.as_str(), "trackingType") => {}
None => ex.warn(format!("{label} ▸ {}: text animator property not supported by Lottie", p.name)),
}
}
}
let mut sel = json!({"t": 0});
let sels: Vec<&PropGroup> = a.sub("selectors").map(|s| s.groups().collect()).unwrap_or_default();
if sels.len() > 1 {
ex.warn(format!("{label}: only the first selector is exported (Lottie animators have one)"));
}
if let Some(s) = sels.first() {
if s.match_id != "rangeSelector" {
ex.warn(format!("{label}: {} is not supported by Lottie", s.name));
} else {
for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
if let Some(p) = s.get(m) {
sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
}
}
if let Some(adv) = s.sub("advanced") {
let e = |m: &str| adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0) + 1;
sel["r"] = json!(e("units"));
sel["b"] = json!(e("basedOn"));
sel["m"] = json!({"a": 0, "k": e("mode")});
sel["sh"] = json!(e("shape"));
sel["rn"] = json!(adv.get("randomize").is_some_and(|p| p.value.as_bool()) as u8);
for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
if let Some(p) = adv.get(m) {
sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
}
}
}
}
}
json!({"nm": a.name, "s": sel, "a": Json::Object(props)})
}
/// Import Lottie text data into a text layer's `text` group.
pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGroup, fonts: &[(String, String, String)]) {
if let Some(list) = t.get("d").and_then(|d| d.get("k")).and_then(Json::as_array)
&& let Some(st) = tg.get_mut("sourceText")
{
let docs: Vec<(f64, TextDoc)> =
list.iter().filter_map(|k| Some((k.get("t").and_then(Json::as_f64).unwrap_or(0.0), doc_from(k.get("s")?, fonts)))).collect();
if let Some((_, first)) = docs.first() {
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();
}
}
if let Some(m) = t.get("m")
&& let Some(more) = tg.sub_mut("moreOptions")
{
if let (Some(g), Some(p)) = (m.get("g").and_then(Json::as_u64), more.get_mut("anchorGrouping")) {
p.value = Value::Enum((g.max(1) - 1).min(3) as u32);
}
if let (Some(a), Some(p)) = (m.get("a"), more.get_mut("groupingAlignment")) {
import_prop(im, a, p, &anim::to_vec2);
}
}
let Some(list) = t.get("a").and_then(Json::as_array) else { return };
let mut animators = vec![];
for (i, a) in list.iter().enumerate() {
let name = a.get("nm").and_then(Json::as_str).map(str::to_string).unwrap_or(format!("Animator {}", i + 1));
let props_j = a.get("a").cloned().unwrap_or(json!({}));
let mut kinds: Vec<&str> = vec![];
for (key, kind, _) in ANIM_PROPS {
if props_j.get(*key).is_some() && !kinds.contains(kind) {
kinds.push(kind);
}
}
let props: Vec<_> = kinds.iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect();
let mut g = build::text_animator(ids, &name, props);
if let Some(pg) = g.sub_mut("properties") {
for (key, _, m) in ANIM_PROPS {
let (Some(j), Some(p)) = (props_j.get(*key), pg.get_mut(m)) else { continue };
let conv: Box<dyn Fn(&Json) -> Option<Value>> = match &p.value {
Value::Vec3(d) => {
let z = d[2];
Box::new(move |v| anim::to_vec3(v, z))
}
Value::Vec2(_) => Box::new(anim::to_vec2),
Value::Color(_) => Box::new(anim::to_color),
_ => Box::new(anim::to_scalar),
};
import_prop(im, j, p, &*conv);
}
}
if let Some(s) = a.get("s")
&& let Some(sel) = g.sub_mut("selectors").and_then(|s| s.groups_mut_first())
{
for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
if let (Some(j), Some(p)) = (s.get(k), sel.get_mut(m)) {
import_prop(im, j, p, &anim::to_scalar);
}
}
if let Some(adv) = sel.sub_mut("advanced") {
let mut en = |m: &str, key: &str, max: u32| {
let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied());
if let (Some(v), Some(p)) = (v, adv.get_mut(m)) {
p.value = Value::Enum(((v.round() as i64 - 1).max(0) as u32).min(max));
}
};
en("units", "r", 1);
en("basedOn", "b", 3);
en("mode", "m", 5);
en("shape", "sh", 5);
if let (Some(rn), Some(p)) = (s.get("rn").and_then(Json::as_u64), adv.get_mut("randomize")) {
p.value = Value::Bool(rn != 0);
}
for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
if let (Some(j), Some(p)) = (s.get(k), adv.get_mut(m)) {
import_prop(im, j, p, &anim::to_scalar);
}
}
}
}
animators.push(g);
}
if let Some(list) = tg.sub_mut("animators") {
list.children.extend(animators.into_iter().map(Node::Group));
}
}
trait FirstGroup {
fn groups_mut_first(&mut self) -> Option<&mut PropGroup>;
}
impl FirstGroup for PropGroup {
fn groups_mut_first(&mut self) -> Option<&mut PropGroup> {
self.children.iter_mut().find_map(Node::as_group_mut)
}
}
/// Text converted to glyph shapes: one group with every glyph outline (at layer time 0's
/// Source Text), a fill and a stroke, as Lottie shape items.
pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec<Json> {
let Some(tg) = layer.props.sub("text") else { return vec![] };
let Some(st) = tg.get("sourceText") else { return vec![] };
let Value::Text(doc) = st.value_at(Tick::ZERO) else { return vec![] };
if st.keys.len() > 1 || tg.sub("animators").is_some_and(|a| !a.children.is_empty()) {
ex.warn(format!("{}: text exported as static glyph shapes (Source Text keys and animators are baked at time 0)", layer.name));
}
let lay = effectcraft_text::layout_doc(&doc);
let mut items = vec![];
for g in &lay.glyphs {
let p = kurbo::Affine::translate((g.origin.x, g.origin.y)) * g.path.clone();
for sp in effectcraft_path::from_kurbo(&p) {
items.push(json!({"ty": "sh", "nm": g.ch.to_string(), "d": 1, "ks": {"a": 0, "k": shapes::shape_json(&sp)}}));
}
}
let col = |c: [f32; 4]| json!({"a": 0, "k": [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]});
let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then(
|| json!({"ty": "st", "nm": "Stroke", "c": col(doc.stroke), "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": doc.stroke_width}, "lc": 2, "lj": 2, "ml": 4}),
);
let fill = doc.apply_fill.then(|| json!({"ty": "fl", "nm": "Fill", "c": col(doc.fill), "o": {"a": 0, "k": 100}, "r": 1}));
// Items above draw on top: stroke over fill puts the stroke first.
if doc.stroke_over_fill {
items.extend(stroke);
items.extend(fill);
} else {
items.extend(fill);
items.extend(stroke);
}
let n = items.len() + 1;
items.push(json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}));
vec![json!({"ty": "gr", "nm": layer.name, "np": n, "it": items})]
}

104
crates/lottie/src/zip.rs Normal file
View File

@ -0,0 +1,104 @@
//! A minimal ZIP writer (stored entries, PKWARE APPNOTE 6.3 local headers + central directory)
//! for `.lottie` (dotLottie) archives.
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
/// Build a ZIP archive with the given (name, bytes) entries, uncompressed.
pub fn store(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
let put16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_le_bytes());
let put32 = |v: &mut Vec<u8>, x: u32| v.extend_from_slice(&x.to_le_bytes());
for (name, data) in entries {
let offset = out.len() as u32;
let crc = crc32(data);
let n = name.as_bytes();
// Local file header.
put32(&mut out, 0x0403_4b50);
put16(&mut out, 20); // version needed
put16(&mut out, 0x0800); // flags: UTF-8 names
put16(&mut out, 0); // stored
put16(&mut out, 0); // time
put16(&mut out, 0x21); // date (1980-01-01)
put32(&mut out, crc);
put32(&mut out, data.len() as u32);
put32(&mut out, data.len() as u32);
put16(&mut out, n.len() as u16);
put16(&mut out, 0);
out.extend_from_slice(n);
out.extend_from_slice(data);
// Central directory record.
put32(&mut central, 0x0201_4b50);
put16(&mut central, 20); // made by
put16(&mut central, 20);
put16(&mut central, 0x0800);
put16(&mut central, 0);
put16(&mut central, 0);
put16(&mut central, 0x21);
put32(&mut central, crc);
put32(&mut central, data.len() as u32);
put32(&mut central, data.len() as u32);
put16(&mut central, n.len() as u16);
put16(&mut central, 0); // extra
put16(&mut central, 0); // comment
put16(&mut central, 0); // disk
put16(&mut central, 0); // internal attrs
put32(&mut central, 0); // external attrs
put32(&mut central, offset);
central.extend_from_slice(n);
}
let cd_offset = out.len() as u32;
let cd_len = central.len() as u32;
out.extend_from_slice(&central);
put32(&mut out, 0x0605_4b50);
put16(&mut out, 0);
put16(&mut out, 0);
put16(&mut out, entries.len() as u16);
put16(&mut out, entries.len() as u16);
put32(&mut out, cd_len);
put32(&mut out, cd_offset);
put16(&mut out, 0);
out
}
/// Read the stored (uncompressed) entries of a ZIP archive written by [`store`] or any other
/// writer that did not compress. Compressed entries are skipped.
pub fn read_stored(data: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut out = vec![];
let mut i = 0usize;
let u16_at = |p: usize| data.get(p..p + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
let u32_at = |p: usize| data.get(p..p + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
while u32_at(i) == Some(0x0403_4b50) {
let (Some(method), Some(size), Some(nlen), Some(xlen)) = (u16_at(i + 8), u32_at(i + 18), u16_at(i + 26), u16_at(i + 28)) else { break };
let name_start = i + 30;
let body = name_start + nlen + xlen;
let (Some(name), Some(bytes)) = (data.get(name_start..name_start + nlen), data.get(body..body + size)) else { break };
if method == 0 {
out.push((String::from_utf8_lossy(name).to_string(), bytes.to_vec()));
}
i = body + size;
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn crc_and_roundtrip() {
assert_eq!(super::crc32(b"123456789"), 0xCBF4_3926);
let z = super::store(&[("a.json".into(), b"{}".to_vec()), ("b/c.txt".into(), b"hello".to_vec())]);
let back = super::read_stored(&z);
assert_eq!(back.len(), 2);
assert_eq!(back[1].0, "b/c.txt");
assert_eq!(back[1].1, b"hello");
}
}

View File

@ -0,0 +1,51 @@
{
"v": "5.12.0",
"nm": "Bouncing Ball",
"fr": 30,
"ip": 0,
"op": 30,
"w": 200,
"h": 200,
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "Ball",
"sr": 1,
"ks": {
"a": { "a": 0, "k": [0, 0, 0] },
"p": {
"a": 1,
"k": [
{ "t": 0, "s": [100, 40, 0], "o": { "x": 0.33, "y": 0 }, "i": { "x": 0.67, "y": 1 }, "to": [0, 0, 0], "ti": [0, 0, 0] },
{ "t": 15, "s": [100, 160, 0], "o": { "x": 0.33, "y": 0 }, "i": { "x": 0.67, "y": 1 }, "to": [0, 0, 0], "ti": [0, 0, 0] },
{ "t": 30, "s": [100, 40, 0] }
]
},
"s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
},
"ao": 0,
"ip": 0,
"op": 30,
"st": 0,
"bm": 0,
"shapes": [
{
"ty": "gr",
"nm": "Ball",
"it": [
{ "ty": "el", "nm": "Circle", "d": 1, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [40, 40] } },
{ "ty": "fl", "nm": "Fill", "c": { "a": 0, "k": [0.95, 0.35, 0.2, 1] }, "o": { "a": 0, "k": 100 }, "r": 1 },
{ "ty": "tr", "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }
]
}
]
}
],
"markers": []
}

View File

@ -0,0 +1,7 @@
asset: bouncing-ball.json
title: Lottie fixture: a ball bouncing with eased position keys
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M11.4)
notes: Hand-written Lottie JSON for import tests, written from the public Lottie format description; not exported from any Adobe product or third-party file.

View File

@ -0,0 +1,51 @@
{
"v": "4.8.0",
"nm": "Legacy Masks",
"fr": 30,
"ip": 0,
"op": 30,
"w": 120,
"h": 120,
"assets": [],
"layers": [
{
"ind": 1,
"ty": 1,
"nm": "Card",
"sc": "#e0c040",
"sw": 120,
"sh": 120,
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"hasMask": true,
"ks": {
"a": { "a": 0, "k": [60, 60, 0] },
"p": { "a": 0, "k": [60, 60, 0] },
"s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 1, "k": [{ "t": 0, "s": [0], "e": [100], "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] } }, { "t": 20 }] }
},
"masksProperties": [
{
"nm": "Outer",
"mode": "a",
"inv": false,
"pt": { "a": 0, "k": { "c": true, "v": [[10, 10], [110, 10], [110, 110], [10, 110]], "i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]] } },
"o": { "a": 0, "k": 100 },
"x": { "a": 0, "k": 0 }
},
{
"nm": "Hole",
"mode": "s",
"inv": true,
"pt": { "a": 0, "k": { "c": true, "v": [[40, 40], [80, 40], [80, 80], [40, 80]], "i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]] } },
"o": { "a": 0, "k": 100 },
"x": { "a": 0, "k": 0 }
}
]
},
{ "ind": 2, "ty": 6, "nm": "Sound", "ip": 0, "op": 30, "st": 0, "sr": 1 }
]
}

View File

@ -0,0 +1,7 @@
asset: legacy-masks.json
title: Lottie fixture: legacy (e-value) keyframes, add/subtract masks and an unsupported layer type
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M11.4)
notes: Hand-written Lottie JSON for import tests, written from the public Lottie format description; not exported from any Adobe product or third-party file.

View File

@ -0,0 +1,97 @@
{
"v": "5.12.0",
"nm": "Precomp Matte Text",
"fr": 30,
"ip": 0,
"op": 30,
"w": 240,
"h": 160,
"fonts": { "list": [{ "fName": "Inter-Regular", "fFamily": "Inter", "fStyle": "Regular", "ascent": 75 }] },
"assets": [
{
"id": "comp_bg",
"nm": "Background",
"w": 240,
"h": 160,
"layers": [
{
"ind": 1,
"ty": 1,
"nm": "Teal",
"sc": "#1a8c8c",
"sw": 240,
"sh": 160,
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"ks": { "a": { "a": 0, "k": [120, 80, 0] }, "p": { "a": 0, "k": [120, 80, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }
}
]
}
],
"layers": [
{
"ind": 1,
"ty": 4,
"nm": "Matte",
"td": 1,
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"ks": { "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [120, 80] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } },
"shapes": [
{ "ty": "el", "nm": "Oval", "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [200, 120] } },
{ "ty": "fl", "nm": "Fill", "c": { "a": 0, "k": [1, 1, 1, 1] }, "o": { "a": 0, "k": 100 } }
]
},
{
"ind": 2,
"ty": 0,
"nm": "Background",
"refId": "comp_bg",
"tt": 1,
"w": 240,
"h": 160,
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"ks": { "a": { "a": 0, "k": [120, 80, 0] }, "p": { "a": 0, "k": [120, 80, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }
},
{
"ind": 3,
"ty": 5,
"nm": "Title",
"parent": 4,
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"ks": { "a": { "a": 0, "k": [0, 0, 0] }, "p": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } },
"t": {
"d": { "k": [{ "t": 0, "s": { "t": "Hello\rLottie", "f": "Inter-Regular", "s": 28, "fc": [1, 1, 1], "j": 2, "tr": 0, "lh": 33.6, "ls": 0 } }] },
"a": [],
"m": { "g": 1, "a": { "a": 0, "k": [0, 0] } },
"p": {}
}
},
{
"ind": 4,
"ty": 3,
"nm": "Controller",
"ip": 0,
"op": 30,
"st": 0,
"sr": 1,
"ks": {
"a": { "a": 0, "k": [0, 0, 0] },
"p": { "a": 1, "k": [{ "t": 0, "s": [120, 60, 0], "o": { "x": 0.5, "y": 0 }, "i": { "x": 0.5, "y": 1 } }, { "t": 29, "s": [120, 100, 0] }] },
"s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
}
]
}

View File

@ -0,0 +1,7 @@
asset: precomp-matte-text.json
title: Lottie fixture: precomp asset with a solid, alpha track matte, parented text layer and a null
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M11.4)
notes: Hand-written Lottie JSON for import tests, written from the public Lottie format description; not exported from any Adobe product or third-party file.

View File

@ -0,0 +1,67 @@
{
"v": "5.12.0",
"nm": "Shapes and Gradient",
"fr": 24,
"ip": 0,
"op": 24,
"w": 160,
"h": 120,
"assets": [],
"layers": [
{
"ind": 1,
"ty": 4,
"nm": "Shapes",
"sr": 1,
"ks": { "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [80, 60] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } },
"ip": 0,
"op": 24,
"st": 0,
"bm": 0,
"shapes": [
{
"ty": "gr",
"nm": "Badge",
"it": [
{ "ty": "sr", "nm": "Star", "sy": 1, "d": 1, "pt": { "a": 0, "k": 6 }, "p": { "a": 0, "k": [-30, 0] }, "r": { "a": 0, "k": 0 }, "ir": { "a": 0, "k": 12 }, "or": { "a": 0, "k": 28 }, "is": { "a": 0, "k": 0 }, "os": { "a": 0, "k": 0 } },
{ "ty": "rc", "nm": "Box", "d": 1, "p": { "a": 0, "k": [30, 0] }, "s": { "a": 0, "k": [50, 40] }, "r": { "a": 0, "k": 8 } },
{
"ty": "gf",
"nm": "Gradient",
"o": { "a": 0, "k": 100 },
"r": 1,
"s": { "a": 0, "k": [-60, 0] },
"e": { "a": 0, "k": [60, 0] },
"t": 1,
"g": { "p": 2, "k": { "a": 0, "k": [0, 0.1, 0.4, 0.9, 1, 0.9, 0.2, 0.5] } }
},
{
"ty": "st",
"nm": "Outline",
"c": { "a": 0, "k": [1, 1, 1, 1] },
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 2 },
"lc": 2,
"lj": 2,
"ml": 4,
"d": [
{ "n": "d", "nm": "dash", "v": { "a": 0, "k": 4 } },
{ "n": "g", "nm": "gap", "v": { "a": 0, "k": 2 } },
{ "n": "o", "nm": "offset", "v": { "a": 0, "k": 0 } }
]
},
{
"ty": "tm",
"nm": "Trim",
"s": { "a": 0, "k": 0 },
"e": { "a": 1, "k": [{ "t": 0, "s": [10], "o": { "x": [0.167], "y": [0.167] }, "i": { "x": [0.833], "y": [0.833] } }, { "t": 23, "s": [100] }] },
"o": { "a": 0, "k": 0 },
"m": 1
},
{ "ty": "tr", "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }
]
}
]
}
]
}

View File

@ -0,0 +1,7 @@
asset: shapes-gradient.json
title: Lottie fixture: star, rounded rectangle, linear gradient fill, dashed stroke and animated trim paths
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M11.4)
notes: Hand-written Lottie JSON for import tests, written from the public Lottie format description; not exported from any Adobe product or third-party file.

View File

@ -484,6 +484,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
"view.exportGuides" => ("path", Ask::Save("Guides.json")), "view.exportGuides" => ("path", Ask::Save("Guides.json")),
"view.importGuides" => ("path", Ask::Open(&["json"])), "view.importGuides" => ("path", Ask::Open(&["json"])),
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])), "file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
"file.replaceFootage" => ("path", Ask::Import), "file.replaceFootage" => ("path", Ask::Import),
"file.collectFiles" => ("folder", Ask::Save("Collected Files")), "file.collectFiles" => ("folder", Ask::Save("Collected Files")),

View File

@ -95,8 +95,15 @@ effectcraft-cli get Main '#1' transform/position --time 0.5 main.ecproj --json
effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json
effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json
effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app
effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","includeExpressions":true}' main.ecproj --json
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
``` ```
`file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie
cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.
`file.importLottie` returns `{comp, items, warnings}` and opens the new composition.
`<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`, `<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`,
`"#ff0000"`) or a bare string. `"#ff0000"`) or a bare string.

View File

@ -26,6 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache | | L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model | | L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio | | L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus | | L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends | | L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel | | L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
@ -33,7 +34,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`, Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
`media / expr / export → render`, `export → media`, `host → engine`. `media / expr / export → render`, `export → media`, `lottie → format`, `host → engine`.
## 2. Time ## 2. Time