mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
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:
co-authored by
Claude Opus 5.5
parent
5273f1ba96
commit
c4100bcf80
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
Reference in New Issue
Block a user