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
co-authored by Claude Opus 5.5
parent 5273f1ba96
commit c4100bcf80
32 changed files with 3767 additions and 4 deletions
+67
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),
]
}
+2
View File
@@ -15,6 +15,7 @@ mod layer;
mod layer_menu;
mod layer_time;
mod link;
mod lottie;
mod mask;
mod prop;
mod query;
@@ -100,6 +101,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs());
v.extend(view::specs());
v.extend(file_more::specs());
v.extend(lottie::specs());
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(stubs::specs());
+2
View File
@@ -454,6 +454,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_styles;
+2
View File
@@ -307,8 +307,10 @@ File
Multiple Files... | file.importMultiple
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
Lottie... | file.importLottie
Export
Add to Render Queue | renderQueue.add
Lottie JSON... | file.exportLottie
---
Find | app.find
---
+60
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())));
}