From c4100bcf80285f268206fad6fc031b60f930bda5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:56:03 -0400 Subject: [PATCH] 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 --- ATTRIBUTION.md | 4 + Cargo.lock | 18 + Cargo.toml | 1 + README.md | 10 +- ROADMAP.md | 2 +- crates/engine/Cargo.toml | 1 + crates/engine/src/commands/lottie.rs | 67 ++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/menus.rs | 2 + crates/engine/src/tests_lottie.rs | 60 ++ crates/lottie/Cargo.toml | 25 + crates/lottie/src/anim.rs | 376 ++++++++++ crates/lottie/src/base64.rs | 69 ++ crates/lottie/src/export.rs | 513 +++++++++++++ crates/lottie/src/import.rs | 463 ++++++++++++ crates/lottie/src/lib.rs | 99 +++ crates/lottie/src/shapes.rs | 578 +++++++++++++++ crates/lottie/src/tests.rs | 700 ++++++++++++++++++ crates/lottie/src/text.rs | 369 +++++++++ crates/lottie/src/zip.rs | 104 +++ .../lottie/tests/fixtures/bouncing-ball.json | 51 ++ .../fixtures/bouncing-ball.json.attribution | 7 + .../lottie/tests/fixtures/legacy-masks.json | 51 ++ .../fixtures/legacy-masks.json.attribution | 7 + .../tests/fixtures/precomp-matte-text.json | 97 +++ .../precomp-matte-text.json.attribution | 7 + .../tests/fixtures/shapes-gradient.json | 67 ++ .../fixtures/shapes-gradient.json.attribution | 7 + crates/ui-egui/src/menus.rs | 2 + docs/agents.md | 7 + docs/architecture.md | 3 +- 32 files changed, 3767 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/commands/lottie.rs create mode 100644 crates/engine/src/tests_lottie.rs create mode 100644 crates/lottie/Cargo.toml create mode 100644 crates/lottie/src/anim.rs create mode 100644 crates/lottie/src/base64.rs create mode 100644 crates/lottie/src/export.rs create mode 100644 crates/lottie/src/import.rs create mode 100644 crates/lottie/src/lib.rs create mode 100644 crates/lottie/src/shapes.rs create mode 100644 crates/lottie/src/tests.rs create mode 100644 crates/lottie/src/text.rs create mode 100644 crates/lottie/src/zip.rs create mode 100644 crates/lottie/tests/fixtures/bouncing-ball.json create mode 100644 crates/lottie/tests/fixtures/bouncing-ball.json.attribution create mode 100644 crates/lottie/tests/fixtures/legacy-masks.json create mode 100644 crates/lottie/tests/fixtures/legacy-masks.json.attribution create mode 100644 crates/lottie/tests/fixtures/precomp-matte-text.json create mode 100644 crates/lottie/tests/fixtures/precomp-matte-text.json.attribution create mode 100644 crates/lottie/tests/fixtures/shapes-gradient.json create mode 100644 crates/lottie/tests/fixtures/shapes-gradient.json.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fb8f22e8..10635b69 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,6 +17,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | | `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 | +| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 | +| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 | +| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 | +| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 | ## Screenshots diff --git a/Cargo.lock b/Cargo.lock index 8519ae6b..abc7c38f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,6 +1191,7 @@ dependencies = [ "effectcraft-effects", "effectcraft-geom", "effectcraft-keyframe", + "effectcraft-lottie", "effectcraft-path", "effectcraft-project", "effectcraft-raster", @@ -1275,6 +1276,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "effectcraft-lottie" +version = "0.1.0" +dependencies = [ + "effectcraft-color", + "effectcraft-effects", + "effectcraft-keyframe", + "effectcraft-path", + "effectcraft-project", + "effectcraft-render", + "effectcraft-text", + "effectcraft-time", + "kurbo", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "effectcraft-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a22b766c..241a3c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ effectcraft-render = { path = "crates/render" } effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } +effectcraft-lottie = { path = "crates/lottie" } effectcraft-engine = { path = "crates/engine" } effectcraft-host = { path = "crates/host" } effectcraft-ui-egui = { path = "crates/ui-egui" } diff --git a/README.md b/README.md index 47d5e5af..576c8093 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,11 @@ A Render Queue like After Effects', with Render Settings and Output Modules: **H sequences, and animated GIF. The same queue runs from the command line. Every encoder is FilmCraft's pure-Rust code; there is no FFmpeg inside. +**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape, +solid, image, text and null layers, eased and spatial keyframes, masks, track mattes, blend +modes, time remapping, optionally expressions) as `.json` or `.lottie`, and lists anything Lottie +cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. +

The Render Queue panel with two compositions queued

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