M11.4: Lottie export/import
New crate effectcraft-lottie (L3): export a comp to Lottie JSON / dotLottie (precomp and base64 image assets; shape, solid, null, precomp, image and text layers or glyph shapes; transforms with parenting, separated position, anchor; keyframes with speed/influence converted to normalised i/o tangents, hold keys, spatial ti/to; all shape items incl. gradients, dashes, trim, repeater and path operators; masks, track mattes, blend modes, time remap, timing, markers, optional expressions, a few Lottie effects) with a warnings list for what Lottie cannot express, and the inverse import. Commands file.exportLottie / file.importLottie with File > Export / Import entries. Round-trip tests (values and rendered frames) and hand-written fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5273f1ba96
commit
c4100bcf80
|
|
@ -17,6 +17,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||||
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
|
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
|
||||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
|
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
|
||||||
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 |
|
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 |
|
||||||
|
| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
|
||||||
|
| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
|
||||||
|
| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
|
||||||
|
| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 |
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1191,6 +1191,7 @@ dependencies = [
|
||||||
"effectcraft-effects",
|
"effectcraft-effects",
|
||||||
"effectcraft-geom",
|
"effectcraft-geom",
|
||||||
"effectcraft-keyframe",
|
"effectcraft-keyframe",
|
||||||
|
"effectcraft-lottie",
|
||||||
"effectcraft-path",
|
"effectcraft-path",
|
||||||
"effectcraft-project",
|
"effectcraft-project",
|
||||||
"effectcraft-raster",
|
"effectcraft-raster",
|
||||||
|
|
@ -1275,6 +1276,23 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "effectcraft-lottie"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"effectcraft-color",
|
||||||
|
"effectcraft-effects",
|
||||||
|
"effectcraft-keyframe",
|
||||||
|
"effectcraft-path",
|
||||||
|
"effectcraft-project",
|
||||||
|
"effectcraft-render",
|
||||||
|
"effectcraft-text",
|
||||||
|
"effectcraft-time",
|
||||||
|
"kurbo",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "effectcraft-media"
|
name = "effectcraft-media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ effectcraft-render = { path = "crates/render" }
|
||||||
effectcraft-media = { path = "crates/media" }
|
effectcraft-media = { path = "crates/media" }
|
||||||
effectcraft-export = { path = "crates/export" }
|
effectcraft-export = { path = "crates/export" }
|
||||||
effectcraft-expr = { path = "crates/expr" }
|
effectcraft-expr = { path = "crates/expr" }
|
||||||
|
effectcraft-lottie = { path = "crates/lottie" }
|
||||||
effectcraft-engine = { path = "crates/engine" }
|
effectcraft-engine = { path = "crates/engine" }
|
||||||
effectcraft-host = { path = "crates/host" }
|
effectcraft-host = { path = "crates/host" }
|
||||||
effectcraft-ui-egui = { path = "crates/ui-egui" }
|
effectcraft-ui-egui = { path = "crates/ui-egui" }
|
||||||
|
|
|
||||||
10
README.md
10
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
|
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
|
||||||
FilmCraft's pure-Rust code; there is no FFmpeg inside.
|
FilmCraft's pure-Rust code; there is no FFmpeg inside.
|
||||||
|
|
||||||
|
**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,
|
||||||
|
solid, image, text and null layers, eased and spatial keyframes, masks, track mattes, blend
|
||||||
|
modes, time remapping, optionally expressions) as `.json` or `.lottie`, and lists anything Lottie
|
||||||
|
cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%">
|
<img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -154,7 +159,8 @@ them:
|
||||||
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
|
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
|
||||||
- **Command line:** one-shot calls with JSON output, for example
|
- **Command line:** one-shot calls with JSON output, for example
|
||||||
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save`
|
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save`
|
||||||
or `effectcraft-cli render --comp Main --out main.mp4`.
|
or `effectcraft-cli render --comp Main --out main.mp4`, or
|
||||||
|
`effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json"}' main.ecproj`.
|
||||||
- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
|
- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
|
||||||
and click any widget by its automation id, and take screenshots.
|
and click any widget by its automation id, and take screenshots.
|
||||||
|
|
||||||
|
|
@ -180,7 +186,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||||
|
|
||||||
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
|
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
|
||||||
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
|
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
|
||||||
paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build
|
paint and roto tools, preferences and a shortcut editor, and GPU compositing. The web build
|
||||||
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
|
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
|
||||||
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.
|
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ once.
|
||||||
| M8 | Expressions with the After Effects object model | Done |
|
| M8 | Expressions with the After Effects object model | Done |
|
||||||
| M9 | Text and effects: text animators, layer styles, 259 effects incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
|
| M9 | Text and effects: text animators, layer styles, 259 effects incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
|
||||||
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
|
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
|
||||||
| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
|
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
|
||||||
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
||||||
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
|
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
|
||||||
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ effectcraft-path = { workspace = true }
|
||||||
effectcraft-text = { workspace = true }
|
effectcraft-text = { workspace = true }
|
||||||
effectcraft-effects = { workspace = true }
|
effectcraft-effects = { workspace = true }
|
||||||
effectcraft-render = { workspace = true }
|
effectcraft-render = { workspace = true }
|
||||||
|
effectcraft-lottie = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! File ▸ Export ▸ Lottie JSON… and File ▸ Import ▸ Lottie… (`effectcraft-lottie`).
|
||||||
|
|
||||||
|
use effectcraft_lottie::{ExportOptions, ImportedImage};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, str_p};
|
||||||
|
use crate::{EngineError, Result, Session, cmd};
|
||||||
|
|
||||||
|
fn export_lottie(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let path = str_p(p, "path").ok_or_else(|| bad("file.exportLottie", "missing `path`"))?.to_string();
|
||||||
|
let cid = comp_id(s, p)?;
|
||||||
|
let opts = ExportOptions { include_expressions: b_p(p, "includeExpressions").unwrap_or(false), text_as_shapes: b_p(p, "textAsShapes").unwrap_or(false) };
|
||||||
|
let services = s.services.clone();
|
||||||
|
let read = move |f: &str| services.read_file(f).ok();
|
||||||
|
let res = effectcraft_lottie::export_comp(&s.project, cid, &opts, &read).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||||
|
let dot = path.to_ascii_lowercase().ends_with(".lottie");
|
||||||
|
let bytes = if dot {
|
||||||
|
let stem = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
|
effectcraft_lottie::to_dotlottie(&res, &stem)
|
||||||
|
} else {
|
||||||
|
res.to_string_compact().into_bytes()
|
||||||
|
};
|
||||||
|
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||||
|
let n = res.warnings.len();
|
||||||
|
s.toast(if n == 0 { format!("Exported Lottie to {path}") } else { format!("Exported Lottie to {path} ({n} warning{})", if n == 1 { "" } else { "s" }) });
|
||||||
|
Ok(json!({"path": path, "bytes": bytes.len(), "warnings": res.warnings}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_lottie(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let path = str_p(p, "path").ok_or_else(|| bad("file.importLottie", "missing `path`"))?.to_string();
|
||||||
|
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||||
|
let fp = std::path::Path::new(&path);
|
||||||
|
let dir = fp.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
|
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Lottie".into());
|
||||||
|
let services = s.services.clone();
|
||||||
|
// Embedded images are written next to the Lottie file (`<name>_<asset id>.<ext>`).
|
||||||
|
let mut store = |img: &ImportedImage| -> Option<String> {
|
||||||
|
let safe: String = img.id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
|
||||||
|
let file = std::path::Path::new(&dir).join(format!("{stem}_{safe}.{}", img.ext)).to_string_lossy().to_string();
|
||||||
|
services.write_file(&file, &img.bytes).ok().map(|_| file)
|
||||||
|
};
|
||||||
|
let res = s.edit("Import Lottie", None, |proj, st| {
|
||||||
|
let r = effectcraft_lottie::import(proj, &bytes, &stem, &dir, &mut store).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||||
|
st.project_selection = vec![r.comp];
|
||||||
|
Ok(r)
|
||||||
|
})?;
|
||||||
|
s.open_comp(res.comp);
|
||||||
|
if !res.warnings.is_empty() {
|
||||||
|
s.toast(format!("Imported {path} ({} warnings)", res.warnings.len()));
|
||||||
|
}
|
||||||
|
Ok(json!({"comp": res.comp.0, "items": res.items.iter().map(|i| i.0).collect::<Vec<_>>(), "warnings": res.warnings}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![
|
||||||
|
cmd!(
|
||||||
|
"file.exportLottie",
|
||||||
|
"Lottie JSON...",
|
||||||
|
["File", "Export"],
|
||||||
|
None,
|
||||||
|
"{comp?, path (.json or .lottie), includeExpressions?: bool, textAsShapes?: bool}",
|
||||||
|
has_comp,
|
||||||
|
export_lottie
|
||||||
|
),
|
||||||
|
cmd!("file.importLottie", "Lottie...", ["File", "Import"], None, "{path (.json or .lottie)}", always, import_lottie),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ mod layer;
|
||||||
mod layer_menu;
|
mod layer_menu;
|
||||||
mod layer_time;
|
mod layer_time;
|
||||||
mod link;
|
mod link;
|
||||||
|
mod lottie;
|
||||||
mod mask;
|
mod mask;
|
||||||
mod prop;
|
mod prop;
|
||||||
mod query;
|
mod query;
|
||||||
|
|
@ -100,6 +101,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(animation::specs());
|
v.extend(animation::specs());
|
||||||
v.extend(view::specs());
|
v.extend(view::specs());
|
||||||
v.extend(file_more::specs());
|
v.extend(file_more::specs());
|
||||||
|
v.extend(lottie::specs());
|
||||||
v.extend(comp_more::specs());
|
v.extend(comp_more::specs());
|
||||||
v.extend(frontend::specs());
|
v.extend(frontend::specs());
|
||||||
v.extend(stubs::specs());
|
v.extend(stubs::specs());
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,8 @@ mod tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_3d;
|
mod tests_3d;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_lottie;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_menu_cmds;
|
mod tests_menu_cmds;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_styles;
|
mod tests_styles;
|
||||||
|
|
|
||||||
|
|
@ -307,8 +307,10 @@ File
|
||||||
Multiple Files... | file.importMultiple
|
Multiple Files... | file.importMultiple
|
||||||
Placeholder... | file.importPlaceholder
|
Placeholder... | file.importPlaceholder
|
||||||
Solid... | file.importSolid
|
Solid... | file.importSolid
|
||||||
|
Lottie... | file.importLottie
|
||||||
Export
|
Export
|
||||||
Add to Render Queue | renderQueue.add
|
Add to Render Queue | renderQueue.add
|
||||||
|
Lottie JSON... | file.exportLottie
|
||||||
---
|
---
|
||||||
Find | app.find
|
Find | app.find
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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())));
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes.
|
||||||
|
//!
|
||||||
|
//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic
|
||||||
|
//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x`
|
||||||
|
//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease
|
||||||
|
//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and
|
||||||
|
//! value change `Δv`,
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! o.x = influence_out o.y = speed_out · influence_out · d / Δv
|
||||||
|
//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the
|
||||||
|
//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys
|
||||||
|
//! set `h: 1`.
|
||||||
|
|
||||||
|
use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value};
|
||||||
|
use effectcraft_project::{Expression, Property};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
use crate::Timebase;
|
||||||
|
|
||||||
|
/// Export state shared by the whole document.
|
||||||
|
pub(crate) struct Ex {
|
||||||
|
pub tb: Timebase,
|
||||||
|
pub include_expressions: bool,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ex {
|
||||||
|
pub fn warn(&mut self, w: impl Into<String>) {
|
||||||
|
let w = w.into();
|
||||||
|
if !self.warnings.contains(&w) {
|
||||||
|
self.warnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import state.
|
||||||
|
pub(crate) struct Im {
|
||||||
|
pub tb: Timebase,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Im {
|
||||||
|
pub fn warn(&mut self, w: impl Into<String>) {
|
||||||
|
let w = w.into();
|
||||||
|
if !self.warnings.contains(&w) {
|
||||||
|
self.warnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secs(t: effectcraft_time::Tick) -> f64 {
|
||||||
|
t.seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn r6(v: f64) -> f64 {
|
||||||
|
if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array).
|
||||||
|
fn wrap(j: Json) -> Json {
|
||||||
|
match j {
|
||||||
|
Json::Array(_) => j,
|
||||||
|
other => json!([other]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_spatial(p: &Property) -> bool {
|
||||||
|
p.spatial && matches!(p.value, Value::Vec2(_) | Value::Vec3(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
|
||||||
|
/// `label` names the property in warnings.
|
||||||
|
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
|
||||||
|
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
|
||||||
|
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
|
||||||
|
if ex.include_expressions {
|
||||||
|
out["x"] = json!(e.text);
|
||||||
|
} else {
|
||||||
|
ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
|
||||||
|
let keys = &p.keys;
|
||||||
|
let spatial = is_spatial(p);
|
||||||
|
let mut out = Vec::with_capacity(keys.len());
|
||||||
|
for (i, k) in keys.iter().enumerate() {
|
||||||
|
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
|
||||||
|
if i + 1 == keys.len() {
|
||||||
|
if k.out_interp == Interp::Hold || p.hold_only {
|
||||||
|
o["h"] = json!(1);
|
||||||
|
}
|
||||||
|
out.push(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
|
||||||
|
o["h"] = json!(1);
|
||||||
|
out.push(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (ox, oy, ix, iy) = segment_ease(keys, i, spatial);
|
||||||
|
o["o"] = json!({"x": ox, "y": oy});
|
||||||
|
o["i"] = json!({"x": ix, "y": iy});
|
||||||
|
if spatial {
|
||||||
|
let (_, to) = kf::spatial_tangents(keys, i);
|
||||||
|
let (ti, _) = kf::spatial_tangents(keys, i + 1);
|
||||||
|
let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
|
||||||
|
o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
|
||||||
|
o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
out.push(o);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension.
|
||||||
|
fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||||
|
let (a, b) = (&keys[i], &keys[i + 1]);
|
||||||
|
let dur = (secs(b.time) - secs(a.time)).max(1e-12);
|
||||||
|
let ca = a.value.components();
|
||||||
|
let cb = b.value.components();
|
||||||
|
let numeric = !ca.is_empty() && ca.len() == cb.len();
|
||||||
|
let dims = if spatial || !numeric { 1 } else { ca.len() };
|
||||||
|
let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]);
|
||||||
|
for d in 0..dims {
|
||||||
|
let (dv, eo, ei) = if spatial {
|
||||||
|
let len = kf::spatial_segment_length(keys, i);
|
||||||
|
(len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
|
||||||
|
} else if numeric {
|
||||||
|
(cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
|
||||||
|
} else {
|
||||||
|
// Paths, gradients: overall progress 0 → 1.
|
||||||
|
let side = |k: &Keyframe, out: bool| {
|
||||||
|
let interp = if out { k.out_interp } else { k.in_interp };
|
||||||
|
let list = if out { &k.out_ease } else { &k.in_ease };
|
||||||
|
if interp == Interp::Bezier { list.first().copied().unwrap_or_default() } else { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } }
|
||||||
|
};
|
||||||
|
(1.0, Some(side(a, true)), Some(side(b, false)))
|
||||||
|
};
|
||||||
|
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
|
||||||
|
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
|
||||||
|
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||||||
|
if i0 + i1 > 1.0 {
|
||||||
|
let k = 1.0 / (i0 + i1);
|
||||||
|
i0 *= k;
|
||||||
|
i1 *= k;
|
||||||
|
}
|
||||||
|
let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
|
||||||
|
ox.push(r6(i0));
|
||||||
|
oy.push(r6(y0));
|
||||||
|
ix.push(r6(1.0 - i1));
|
||||||
|
iy.push(r6(y1));
|
||||||
|
}
|
||||||
|
(ox, oy, ix, iy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- import
|
||||||
|
|
||||||
|
/// Whether a Lottie property value is keyframed.
|
||||||
|
pub(crate) fn is_animated(j: &Json) -> bool {
|
||||||
|
if j.get("a").and_then(Json::as_i64) == Some(1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array).
|
||||||
|
fn comp(j: &Json, field: &str, d: usize) -> Option<f64> {
|
||||||
|
match j.get(field)? {
|
||||||
|
Json::Number(n) => n.as_f64(),
|
||||||
|
Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec3_of(j: Option<&Json>) -> [f64; 3] {
|
||||||
|
let a = j.and_then(Json::as_array);
|
||||||
|
let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0);
|
||||||
|
[g(0), g(1), g(2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie
|
||||||
|
/// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of
|
||||||
|
/// the property's type.
|
||||||
|
pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option<Value>) {
|
||||||
|
if !j.is_object() {
|
||||||
|
if let Some(v) = conv(j) {
|
||||||
|
p.value = v;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) {
|
||||||
|
p.expr = Some(Expression { text: x.to_string(), enabled: true });
|
||||||
|
}
|
||||||
|
let Some(k) = j.get("k") else { return };
|
||||||
|
if !is_animated(j) {
|
||||||
|
if let Some(v) = conv(k) {
|
||||||
|
p.value = v;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(list) = k.as_array() else { return };
|
||||||
|
let spatial = is_spatial(p);
|
||||||
|
let mut keys: Vec<Keyframe> = Vec::with_capacity(list.len());
|
||||||
|
let mut kept: Vec<&Json> = Vec::with_capacity(list.len());
|
||||||
|
let mut prev_end: Option<&Json> = None;
|
||||||
|
for kj in list {
|
||||||
|
let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0);
|
||||||
|
let sv = kj.get("s").or(prev_end);
|
||||||
|
prev_end = kj.get("e");
|
||||||
|
let Some(v) = sv.and_then(conv) else { continue };
|
||||||
|
let mut key = Keyframe::new(im.tb.tick(t), v);
|
||||||
|
key.spatial_auto = false;
|
||||||
|
if p.hold_only || !key.value.interpolates() {
|
||||||
|
key = key.hold();
|
||||||
|
}
|
||||||
|
keys.push(key);
|
||||||
|
kept.push(kj);
|
||||||
|
}
|
||||||
|
if keys.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = keys.len();
|
||||||
|
for i in 0..n.saturating_sub(1) {
|
||||||
|
let Some(kj) = kept.get(i) else { break };
|
||||||
|
if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only {
|
||||||
|
keys[i].out_interp = Interp::Hold;
|
||||||
|
keys[i + 1].in_interp = Interp::Hold;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if spatial {
|
||||||
|
let to = vec3_of(kj.get("to"));
|
||||||
|
let ti = vec3_of(kj.get("ti"));
|
||||||
|
keys[i].spatial_out = to;
|
||||||
|
keys[i + 1].spatial_in = ti;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Eases need the spatial tangents (arc length), so they come second.
|
||||||
|
for i in 0..n.saturating_sub(1) {
|
||||||
|
let Some(kj) = kept.get(i) else { break };
|
||||||
|
if keys[i].out_interp == Interp::Hold {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else {
|
||||||
|
keys[i].out_interp = Interp::Linear;
|
||||||
|
keys[i + 1].in_interp = Interp::Linear;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let dur = (secs(keys[i + 1].time) - secs(keys[i].time)).max(1e-12);
|
||||||
|
let ca = keys[i].value.components();
|
||||||
|
let cb = keys[i + 1].value.components();
|
||||||
|
let numeric = !ca.is_empty() && ca.len() == cb.len();
|
||||||
|
let dims = if spatial || !numeric { 1 } else { ca.len() };
|
||||||
|
let mut linear = true;
|
||||||
|
let mut outs = vec![];
|
||||||
|
let mut ins = vec![];
|
||||||
|
for d in 0..dims {
|
||||||
|
let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0);
|
||||||
|
let oy = comp(o, "y", d).unwrap_or(ox);
|
||||||
|
let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0);
|
||||||
|
let iy = comp(ii, "y", d).unwrap_or(ix);
|
||||||
|
if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 {
|
||||||
|
linear = false;
|
||||||
|
}
|
||||||
|
let dv = if spatial {
|
||||||
|
kf::spatial_segment_length(&keys, i)
|
||||||
|
} else if numeric {
|
||||||
|
cb[d] - ca[d]
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let inf_o = ox.max(0.001);
|
||||||
|
let inf_i = (1.0 - ix).max(0.001);
|
||||||
|
outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o });
|
||||||
|
ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i });
|
||||||
|
}
|
||||||
|
if linear {
|
||||||
|
keys[i].out_interp = Interp::Linear;
|
||||||
|
keys[i + 1].in_interp = Interp::Linear;
|
||||||
|
} else {
|
||||||
|
keys[i].out_interp = Interp::Bezier;
|
||||||
|
keys[i].out_ease = outs;
|
||||||
|
keys[i + 1].in_interp = Interp::Bezier;
|
||||||
|
keys[i + 1].in_ease = ins;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.value = keys[0].value.clone();
|
||||||
|
p.keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- value converters
|
||||||
|
|
||||||
|
pub(crate) fn num(v: &Value) -> Json {
|
||||||
|
json!(r6(v.as_f64()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn arr(v: &Value) -> Json {
|
||||||
|
match v {
|
||||||
|
Value::Scalar(x) => json!([r6(*x)]),
|
||||||
|
_ => json!(v.components().iter().map(|x| r6(*x)).collect::<Vec<_>>()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vec2 (first two components of any vector).
|
||||||
|
pub(crate) fn arr2(v: &Value) -> Json {
|
||||||
|
let c = v.components();
|
||||||
|
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0))])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vec3 (padded with `z`).
|
||||||
|
pub(crate) fn arr3(v: &Value) -> Json {
|
||||||
|
let c = v.components();
|
||||||
|
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`).
|
||||||
|
pub(crate) fn nums(j: &Json) -> Option<Vec<f64>> {
|
||||||
|
match j {
|
||||||
|
Json::Number(n) => Some(vec![n.as_f64()?]),
|
||||||
|
Json::Array(a) => {
|
||||||
|
// `[[x, y]]`-style wrapping.
|
||||||
|
if a.len() == 1
|
||||||
|
&& let Some(inner @ Json::Array(_)) = a.first()
|
||||||
|
{
|
||||||
|
return nums(inner);
|
||||||
|
}
|
||||||
|
a.iter().map(Json::as_f64).collect()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_scalar(j: &Json) -> Option<Value> {
|
||||||
|
nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_vec2(j: &Json) -> Option<Value> {
|
||||||
|
let v = nums(j)?;
|
||||||
|
Some(Value::Vec2([*v.first()?, *v.get(1).unwrap_or(&0.0)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vec3 with a default third component.
|
||||||
|
pub(crate) fn to_vec3(j: &Json, z: f64) -> Option<Value> {
|
||||||
|
let v = nums(j)?;
|
||||||
|
Some(Value::Vec3([*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&z)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_color(j: &Json) -> Option<Value> {
|
||||||
|
let v = nums(j)?;
|
||||||
|
let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)];
|
||||||
|
// Some files use 0–255.
|
||||||
|
if c[..3].iter().any(|x| *x > 1.0) {
|
||||||
|
for x in &mut c[..3] {
|
||||||
|
*x /= 255.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Color(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_bool(j: &Json) -> Option<Value> {
|
||||||
|
match j {
|
||||||
|
Json::Bool(b) => Some(Value::Bool(*b)),
|
||||||
|
_ => to_scalar(j).map(|v| Value::Bool(v.as_f64() != 0.0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lottie 1-based enum → 0-based popup index.
|
||||||
|
pub(crate) fn to_enum1(j: &Json) -> Option<Value> {
|
||||||
|
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
//! Standard base64 (RFC 4648 §4) for embedded image assets.
|
||||||
|
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
pub fn encode(data: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||||
|
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
||||||
|
for i in 0..4 {
|
||||||
|
if i <= chunk.len() {
|
||||||
|
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let val = |c: u8| -> Option<u32> {
|
||||||
|
Some(match c {
|
||||||
|
b'A'..=b'Z' => c - b'A',
|
||||||
|
b'a'..=b'z' => c - b'a' + 26,
|
||||||
|
b'0'..=b'9' => c - b'0' + 52,
|
||||||
|
b'+' | b'-' => 62,
|
||||||
|
b'/' | b'_' => 63,
|
||||||
|
_ => return None,
|
||||||
|
} as u32)
|
||||||
|
};
|
||||||
|
let clean: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
|
||||||
|
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
||||||
|
for chunk in clean.chunks(4) {
|
||||||
|
let mut n = 0u32;
|
||||||
|
for (i, &c) in chunk.iter().enumerate() {
|
||||||
|
n |= val(c)? << (18 - 6 * i);
|
||||||
|
}
|
||||||
|
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
|
||||||
|
match chunk.len() {
|
||||||
|
4 => out.extend_from_slice(&bytes),
|
||||||
|
3 => out.extend_from_slice(&bytes[..2]),
|
||||||
|
2 => out.push(bytes[0]),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a `data:<mime>;base64,<payload>` URI into (mime, bytes).
|
||||||
|
pub fn data_uri(s: &str) -> Option<(String, Vec<u8>)> {
|
||||||
|
let rest = s.strip_prefix("data:")?;
|
||||||
|
let (head, payload) = rest.split_once(',')?;
|
||||||
|
let mime = head.strip_suffix(";base64")?;
|
||||||
|
Some((mime.to_string(), decode(payload)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] {
|
||||||
|
let e = super::encode(s.as_bytes());
|
||||||
|
assert_eq!(super::decode(&e).unwrap(), s.as_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(super::encode(b"foobar"), "Zm9vYmFy");
|
||||||
|
assert_eq!(super::encode(b"fo"), "Zm8=");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,513 @@
|
||||||
|
//! EffectCraft composition → Lottie document.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use effectcraft_keyframe::Value;
|
||||||
|
use effectcraft_project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, PropGroup};
|
||||||
|
use serde_json::{Map, Value as Json, json};
|
||||||
|
|
||||||
|
use crate::anim::{self, Ex, export_prop};
|
||||||
|
use crate::{LottieError, Timebase, shapes, text};
|
||||||
|
|
||||||
|
/// Export settings.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ExportOptions {
|
||||||
|
/// Export expressions as Lottie `x` strings (players that run expressions evaluate them).
|
||||||
|
pub include_expressions: bool,
|
||||||
|
/// Export text layers as glyph shapes (exact look, no font needed) instead of text layers.
|
||||||
|
pub text_as_shapes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An exported Lottie document and what could not be expressed.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ExportResult {
|
||||||
|
pub json: Json,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportResult {
|
||||||
|
pub fn to_string_compact(&self) -> String {
|
||||||
|
serde_json::to_string(&self.json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One supported effect: Lottie effect type and its parameter list in Lottie order:
|
||||||
|
/// (EffectCraft param id or "" for a Lottie-only param, Lottie value type, scale to Lottie,
|
||||||
|
/// constant value for Lottie-only params).
|
||||||
|
pub(crate) struct FxMap {
|
||||||
|
pub id: &'static str,
|
||||||
|
ty: u32,
|
||||||
|
params: &'static [(&'static str, u32, f64, f64)],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effects Lottie players implement.
|
||||||
|
const EFFECTS: &[FxMap] = &[
|
||||||
|
FxMap { id: "ec.blur.gaussian", ty: 29, params: &[("blurriness", 0, 1.0, 0.0), ("dimensions", 7, 1.0, 0.0), ("repeatEdge", 4, 1.0, 0.0)] },
|
||||||
|
FxMap { id: "ec.color.tint", ty: 20, params: &[("black", 2, 1.0, 0.0), ("white", 2, 1.0, 0.0), ("amount", 0, 1.0, 0.0)] },
|
||||||
|
FxMap {
|
||||||
|
id: "ec.color.tritone",
|
||||||
|
ty: 23,
|
||||||
|
params: &[("highlights", 2, 1.0, 0.0), ("midtones", 2, 1.0, 0.0), ("shadows", 2, 1.0, 0.0), ("blend", 0, 1.0, 0.0)],
|
||||||
|
},
|
||||||
|
FxMap {
|
||||||
|
id: "ec.perspective.dropshadow",
|
||||||
|
ty: 25,
|
||||||
|
params: &[
|
||||||
|
("color", 2, 1.0, 0.0),
|
||||||
|
("opacity", 0, 2.55, 0.0),
|
||||||
|
("direction", 1, 1.0, 0.0),
|
||||||
|
("distance", 0, 1.0, 0.0),
|
||||||
|
("softness", 0, 1.0, 0.0),
|
||||||
|
("shadowOnly", 4, 1.0, 0.0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FxMap {
|
||||||
|
id: "ec.generate.fill",
|
||||||
|
ty: 21,
|
||||||
|
params: &[
|
||||||
|
("", 10, 1.0, 0.0),
|
||||||
|
("", 4, 1.0, 1.0),
|
||||||
|
("color", 2, 1.0, 0.0),
|
||||||
|
("invert", 4, 1.0, 0.0),
|
||||||
|
("", 0, 1.0, 0.0),
|
||||||
|
("", 0, 1.0, 0.0),
|
||||||
|
("opacity", 0, 0.01, 0.0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FxMap { id: "ec.control.slider", ty: 5, params: &[("slider", 0, 1.0, 0.0)] },
|
||||||
|
FxMap { id: "ec.control.angle", ty: 5, params: &[("angle", 1, 1.0, 0.0)] },
|
||||||
|
FxMap { id: "ec.control.color", ty: 5, params: &[("color", 2, 1.0, 0.0)] },
|
||||||
|
FxMap { id: "ec.control.point", ty: 5, params: &[("point", 3, 1.0, 0.0)] },
|
||||||
|
FxMap { id: "ec.control.checkbox", ty: 5, params: &[("checkbox", 4, 1.0, 0.0)] },
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) fn fx_by_id(id: &str) -> Option<&'static FxMap> {
|
||||||
|
EFFECTS.iter().find(|f| f.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fx_by_type(ty: u64, inner_ty: Option<u64>) -> Option<&'static FxMap> {
|
||||||
|
if ty == 5 {
|
||||||
|
return EFFECTS.iter().find(|f| f.ty == 5 && Some(f.params[0].1 as u64) == inner_ty);
|
||||||
|
}
|
||||||
|
EFFECTS.iter().find(|f| f.ty as u64 == ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fx_params(f: &FxMap) -> &'static [(&'static str, u32, f64, f64)] {
|
||||||
|
f.params
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_mode(m: MaskMode) -> &'static str {
|
||||||
|
match m {
|
||||||
|
MaskMode::None => "n",
|
||||||
|
MaskMode::Add => "a",
|
||||||
|
MaskMode::Subtract => "s",
|
||||||
|
MaskMode::Intersect => "i",
|
||||||
|
MaskMode::Lighten => "l",
|
||||||
|
MaskMode::Darken => "d",
|
||||||
|
MaskMode::Difference => "f",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mask_mode_from(s: &str) -> MaskMode {
|
||||||
|
match s {
|
||||||
|
"n" => MaskMode::None,
|
||||||
|
"s" => MaskMode::Subtract,
|
||||||
|
"i" => MaskMode::Intersect,
|
||||||
|
"l" => MaskMode::Lighten,
|
||||||
|
"d" => MaskMode::Darken,
|
||||||
|
"f" => MaskMode::Difference,
|
||||||
|
_ => MaskMode::Add,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matte_code(k: MatteKind) -> u32 {
|
||||||
|
match k {
|
||||||
|
MatteKind::Alpha => 1,
|
||||||
|
MatteKind::AlphaInverted => 2,
|
||||||
|
MatteKind::Luma => 3,
|
||||||
|
MatteKind::LumaInverted => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Doc<'a> {
|
||||||
|
project: &'a Project,
|
||||||
|
opts: &'a ExportOptions,
|
||||||
|
read: &'a dyn Fn(&str) -> Option<Vec<u8>>,
|
||||||
|
ex: Ex,
|
||||||
|
assets: Vec<Json>,
|
||||||
|
done: BTreeSet<String>,
|
||||||
|
fonts: Vec<Json>,
|
||||||
|
any_3d: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(c: [f32; 3]) -> String {
|
||||||
|
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||||
|
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_of(path: &str) -> &'static str {
|
||||||
|
let lower = path.to_ascii_lowercase();
|
||||||
|
if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
|
||||||
|
"image/jpeg"
|
||||||
|
} else if lower.ends_with(".webp") {
|
||||||
|
"image/webp"
|
||||||
|
} else if lower.ends_with(".gif") {
|
||||||
|
"image/gif"
|
||||||
|
} else {
|
||||||
|
"image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Doc<'_> {
|
||||||
|
fn frame(&self, t: effectcraft_time::Tick) -> f64 {
|
||||||
|
self.ex.tb.frame(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option<Json> {
|
||||||
|
let p = g.get(m)?;
|
||||||
|
Some(export_prop(&mut self.ex, p, conv, &format!("{label} ▸ {}", p.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(&mut self, l: &Layer) -> Json {
|
||||||
|
let mut ks = Map::new();
|
||||||
|
let Some(tr) = l.transform() else { return json!({}) };
|
||||||
|
let label = format!("{} ▸ Transform", l.name);
|
||||||
|
let three = l.is_3d();
|
||||||
|
if let Some(a) = self.prop(tr, "anchor", &anim::arr3, &label) {
|
||||||
|
ks.insert("a".into(), a);
|
||||||
|
}
|
||||||
|
if tr.get("positionX").is_some() {
|
||||||
|
let mut p = json!({"s": true});
|
||||||
|
for (k, m) in [("x", "positionX"), ("y", "positionY"), ("z", "positionZ")] {
|
||||||
|
if (k != "z" || three)
|
||||||
|
&& let Some(v) = self.prop(tr, m, &anim::num, &label)
|
||||||
|
{
|
||||||
|
p[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ks.insert("p".into(), p);
|
||||||
|
} else if let Some(p) = self.prop(tr, "position", &anim::arr3, &label) {
|
||||||
|
ks.insert("p".into(), p);
|
||||||
|
}
|
||||||
|
if let Some(s) = self.prop(tr, "scale", &anim::arr3, &label) {
|
||||||
|
ks.insert("s".into(), s);
|
||||||
|
}
|
||||||
|
if three {
|
||||||
|
for (k, m) in [("rx", "rotationX"), ("ry", "rotationY"), ("rz", "rotation")] {
|
||||||
|
if let Some(v) = self.prop(tr, m, &anim::num, &label) {
|
||||||
|
ks.insert(k.into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = self.prop(tr, "orientation", &anim::arr3, &label) {
|
||||||
|
ks.insert("or".into(), v);
|
||||||
|
}
|
||||||
|
} else if let Some(r) = self.prop(tr, "rotation", &anim::num, &label) {
|
||||||
|
ks.insert("r".into(), r);
|
||||||
|
}
|
||||||
|
if let Some(o) = self.prop(tr, "opacity", &anim::num, &label) {
|
||||||
|
ks.insert("o".into(), o);
|
||||||
|
}
|
||||||
|
Json::Object(ks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn masks(&mut self, l: &Layer) -> Vec<Json> {
|
||||||
|
let Some(masks) = l.masks() else { return vec![] };
|
||||||
|
let mut out = vec![];
|
||||||
|
for m in masks.groups() {
|
||||||
|
let GroupKind::Mask { mode, inverted, .. } = m.kind else { continue };
|
||||||
|
let label = format!("{} ▸ {}", l.name, m.name);
|
||||||
|
let mut o = json!({"nm": m.name, "mode": mask_mode(mode), "inv": inverted});
|
||||||
|
if let Some(v) = self.prop(m, "path", &shapes::path_value, &label) {
|
||||||
|
o["pt"] = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = self.prop(m, "opacity", &anim::num, &label) {
|
||||||
|
o["o"] = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = self.prop(m, "expansion", &anim::num, &label) {
|
||||||
|
o["x"] = v;
|
||||||
|
}
|
||||||
|
if let Some(f) = m.get("feather") {
|
||||||
|
if f.is_animated() || f.value.components().iter().any(|v| *v != 0.0) {
|
||||||
|
self.ex.warn(format!("{label}: mask feather is exported as `f` (not every Lottie player renders it)"));
|
||||||
|
}
|
||||||
|
o["f"] = export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather"));
|
||||||
|
}
|
||||||
|
if !m.enabled {
|
||||||
|
o["mode"] = json!("n");
|
||||||
|
}
|
||||||
|
out.push(o);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&mut self, l: &Layer) -> Vec<Json> {
|
||||||
|
let Some(fx) = l.effects() else { return vec![] };
|
||||||
|
let mut out = vec![];
|
||||||
|
for (i, g) in fx.groups().enumerate() {
|
||||||
|
let GroupKind::Effect { effect } = &g.kind else { continue };
|
||||||
|
let label = format!("{} ▸ {}", l.name, g.name);
|
||||||
|
let Some(map) = fx_by_id(effect) else {
|
||||||
|
self.ex.warn(format!("{label}: effect not supported by Lottie (skipped)"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut params = vec![];
|
||||||
|
for (pi, (m, ty, scale, konst)) in map.params.iter().enumerate() {
|
||||||
|
let (nm, v) = match g.get(m).filter(|_| !m.is_empty()) {
|
||||||
|
Some(p) => {
|
||||||
|
let (ty, scale) = (*ty, *scale);
|
||||||
|
let conv = move |v: &Value| -> Json {
|
||||||
|
match ty {
|
||||||
|
2 => anim::arr(v),
|
||||||
|
3 => anim::arr2(v),
|
||||||
|
4 => json!(v.as_bool() as u8),
|
||||||
|
7 => json!(v.as_enum() + 1),
|
||||||
|
_ => json!(v.as_f64() * scale),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(p.name.clone(), export_prop(&mut self.ex, p, &conv, &format!("{label} ▸ {}", p.name)))
|
||||||
|
}
|
||||||
|
None => (format!("Param {}", pi + 1), json!({"a": 0, "k": konst})),
|
||||||
|
};
|
||||||
|
params.push(json!({"ty": ty, "nm": nm, "mn": m, "ix": pi + 1, "v": v}));
|
||||||
|
}
|
||||||
|
out.push(json!({"ty": map.ty, "nm": g.name, "mn": effect, "ix": i + 1, "en": g.enabled as u8, "np": params.len(), "ef": params}));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_asset(&mut self, item: ItemId, l: &Layer) -> Option<String> {
|
||||||
|
let id = format!("image_{}", item.0);
|
||||||
|
if self.done.contains(&id) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
let it = self.project.item(item)?;
|
||||||
|
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||||
|
let Some(bytes) = (self.read)(&f.path) else {
|
||||||
|
self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes));
|
||||||
|
self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1}));
|
||||||
|
self.done.insert(id.clone());
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn precomp_asset(&mut self, item: ItemId) -> Option<String> {
|
||||||
|
let id = format!("comp_{}", item.0);
|
||||||
|
if self.done.contains(&id) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
let it = self.project.item(item)?;
|
||||||
|
let c = it.as_comp()?;
|
||||||
|
self.done.insert(id.clone());
|
||||||
|
let layers = self.layers(c);
|
||||||
|
self.assets.push(json!({"id": id, "nm": it.name, "fr": c.frame_rate.as_f64(), "w": c.width, "h": c.height, "layers": layers}));
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layers(&mut self, comp: &Comp) -> Vec<Json> {
|
||||||
|
let mattes: BTreeSet<_> = comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
|
||||||
|
let mut out = vec![];
|
||||||
|
for (idx, l) in comp.layers.iter().enumerate() {
|
||||||
|
out.push(self.layer(comp, idx, l, mattes.contains(&l.id)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> Json {
|
||||||
|
let name = l.name.clone();
|
||||||
|
let mut o = Map::new();
|
||||||
|
o.insert("ind".into(), json!(idx + 1));
|
||||||
|
o.insert("nm".into(), json!(name));
|
||||||
|
let three = l.is_3d();
|
||||||
|
if three {
|
||||||
|
self.any_3d = true;
|
||||||
|
if !l.is_camera() && !l.is_light() {
|
||||||
|
self.ex.warn(format!("{name}: 3D layer exported with ddd: 1 (most Lottie players render 2D only)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.insert("ddd".into(), json!(three as u8));
|
||||||
|
let mut hidden = !l.switches.video || l.switches.guide;
|
||||||
|
let mut ty = 3;
|
||||||
|
match &l.source {
|
||||||
|
LayerSource::Shape => {
|
||||||
|
ty = 4;
|
||||||
|
let items = l.props.sub("contents").map(|c| shapes::export_items(&mut self.ex, c, &name)).unwrap_or_default();
|
||||||
|
o.insert("shapes".into(), json!(items));
|
||||||
|
}
|
||||||
|
LayerSource::Text => {
|
||||||
|
if self.opts.text_as_shapes {
|
||||||
|
ty = 4;
|
||||||
|
let items = text::glyph_shapes(&mut self.ex, l);
|
||||||
|
o.insert("shapes".into(), json!(items));
|
||||||
|
} else {
|
||||||
|
ty = 5;
|
||||||
|
let t = text::export_text(&mut self.ex, l, &mut self.fonts);
|
||||||
|
o.insert("t".into(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Solid { item } => {
|
||||||
|
let sol = self.project.item(*item).and_then(|i| if let ItemKind::Solid(s) = &i.kind { Some(s.clone()) } else { None });
|
||||||
|
match sol {
|
||||||
|
Some(s) if !l.switches.adjustment => {
|
||||||
|
ty = 1;
|
||||||
|
o.insert("sc".into(), json!(hex(s.color)));
|
||||||
|
o.insert("sw".into(), json!(s.width));
|
||||||
|
o.insert("sh".into(), json!(s.height));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.ex.warn(format!("{name}: adjustment layers are not part of Lottie (exported as a null)"));
|
||||||
|
hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Comp { item } => {
|
||||||
|
if let Some(id) = self.precomp_asset(*item) {
|
||||||
|
ty = 0;
|
||||||
|
let (w, h) = self.project.comp(*item).map(|c| (c.width, c.height)).unwrap_or((comp.width, comp.height));
|
||||||
|
o.insert("refId".into(), json!(id));
|
||||||
|
o.insert("w".into(), json!(w));
|
||||||
|
o.insert("h".into(), json!(h));
|
||||||
|
if let Some(tm) = l.props.get("timeRemap") {
|
||||||
|
let v = export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap"));
|
||||||
|
o.insert("tm".into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Footage { item } => {
|
||||||
|
let kind = self.project.item(*item).and_then(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.has_video)) } else { None });
|
||||||
|
match kind {
|
||||||
|
Some((FootageKind::Still, true)) => {
|
||||||
|
if let Some(id) = self.image_asset(*item, l) {
|
||||||
|
ty = 2;
|
||||||
|
o.insert("refId".into(), json!(id));
|
||||||
|
} else {
|
||||||
|
hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((FootageKind::Audio, _)) | Some((_, false)) => {
|
||||||
|
self.ex.warn(format!("{name}: audio is not part of Lottie (layer exported as a hidden null)"));
|
||||||
|
hidden = true;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.ex.warn(format!("{name}: video and image-sequence footage is not part of Lottie (layer exported as a null)"));
|
||||||
|
hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Null => {}
|
||||||
|
LayerSource::Camera | LayerSource::Light { .. } => {
|
||||||
|
self.ex.warn(format!("{name}: cameras and lights are not exported (3D; exported as a hidden null to keep parenting)"));
|
||||||
|
hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.insert("ty".into(), json!(ty));
|
||||||
|
if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) {
|
||||||
|
o.insert("parent".into(), json!(p));
|
||||||
|
}
|
||||||
|
o.insert("sr".into(), json!(l.stretch / 100.0));
|
||||||
|
if l.stretch < 0.0 {
|
||||||
|
self.ex.warn(format!("{name}: reversed time stretch is not supported by most Lottie players"));
|
||||||
|
}
|
||||||
|
let ks = self.transform(l);
|
||||||
|
o.insert("ks".into(), ks);
|
||||||
|
o.insert("ao".into(), json!((l.auto_orient == effectcraft_project::AutoOrient::AlongPath) as u8));
|
||||||
|
o.insert("ip".into(), json!(self.frame(l.in_point)));
|
||||||
|
o.insert("op".into(), json!(self.frame(l.out_point)));
|
||||||
|
o.insert("st".into(), json!(self.frame(l.start_time)));
|
||||||
|
let bm = match crate::blend_to_lottie(l.blend_mode) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
self.ex.warn(format!("{name}: blend mode {:?} has no Lottie equivalent (Normal used)", l.blend_mode));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
o.insert("bm".into(), json!(bm));
|
||||||
|
let masks = self.masks(l);
|
||||||
|
if !masks.is_empty() {
|
||||||
|
o.insert("hasMask".into(), json!(true));
|
||||||
|
o.insert("masksProperties".into(), json!(masks));
|
||||||
|
}
|
||||||
|
let ef = self.effects(l);
|
||||||
|
if !ef.is_empty() {
|
||||||
|
o.insert("ef".into(), json!(ef));
|
||||||
|
}
|
||||||
|
if let Some(tm) = l.track_matte
|
||||||
|
&& let Some(mi) = comp.index_of(tm.layer)
|
||||||
|
{
|
||||||
|
o.insert("tt".into(), json!(matte_code(tm.kind)));
|
||||||
|
o.insert("tp".into(), json!(mi));
|
||||||
|
if mi != idx {
|
||||||
|
self.ex.warn(format!("{name}: track matte layer is not directly above (players without `tp` support use the layer above)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_matte {
|
||||||
|
o.insert("td".into(), json!(1));
|
||||||
|
if l.switches.video {
|
||||||
|
self.ex.warn(format!("{name}: visible track matte layer (Lottie always hides matte layers)"));
|
||||||
|
}
|
||||||
|
} else if hidden {
|
||||||
|
o.insert("hd".into(), json!(true));
|
||||||
|
}
|
||||||
|
if !l.markers.is_empty() {
|
||||||
|
self.ex.warn(format!("{name}: layer markers are not part of Lottie (composition markers are exported)"));
|
||||||
|
}
|
||||||
|
if l.props.sub(effectcraft_project::styles::GROUP).is_some_and(|g| g.groups().any(|s| s.enabled && s.match_id != effectcraft_project::styles::BLENDING))
|
||||||
|
{
|
||||||
|
self.ex.warn(format!("{name}: layer styles are not exported"));
|
||||||
|
}
|
||||||
|
if l.preserve_transparency {
|
||||||
|
self.ex.warn(format!("{name}: Preserve Underlying Transparency is not part of Lottie"));
|
||||||
|
}
|
||||||
|
Json::Object(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export composition `comp` as a Lottie document. `read` loads footage files (still images are
|
||||||
|
/// embedded as base64 assets).
|
||||||
|
pub fn export_comp(project: &Project, comp: ItemId, opts: &ExportOptions, read: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<ExportResult, LottieError> {
|
||||||
|
let item = project.item(comp).ok_or_else(|| LottieError::Invalid(format!("no item {}", comp.0)))?;
|
||||||
|
let c = item.as_comp().ok_or_else(|| LottieError::Invalid(format!("`{}` is not a composition", item.name)))?;
|
||||||
|
let tb = Timebase { rate: c.frame_rate };
|
||||||
|
let mut doc = Doc {
|
||||||
|
project,
|
||||||
|
opts,
|
||||||
|
read,
|
||||||
|
ex: Ex { tb, include_expressions: opts.include_expressions, warnings: vec![] },
|
||||||
|
assets: vec![],
|
||||||
|
done: BTreeSet::new(),
|
||||||
|
fonts: vec![],
|
||||||
|
any_3d: false,
|
||||||
|
};
|
||||||
|
doc.done.insert(format!("comp_{}", comp.0));
|
||||||
|
let layers = doc.layers(c);
|
||||||
|
let markers: Vec<Json> = c.markers.iter().map(|m| json!({"tm": doc.frame(m.time), "cm": m.comment, "dr": doc.frame(m.duration)})).collect();
|
||||||
|
let mut out = json!({
|
||||||
|
"v": "5.12.0",
|
||||||
|
"fr": c.frame_rate.as_f64(),
|
||||||
|
"ip": 0,
|
||||||
|
"op": doc.frame(c.duration),
|
||||||
|
"w": c.width,
|
||||||
|
"h": c.height,
|
||||||
|
"nm": item.name,
|
||||||
|
"ddd": doc.any_3d as u8,
|
||||||
|
"assets": doc.assets,
|
||||||
|
"layers": layers,
|
||||||
|
"markers": markers,
|
||||||
|
});
|
||||||
|
if !doc.fonts.is_empty() {
|
||||||
|
out["fonts"] = json!({"list": doc.fonts});
|
||||||
|
}
|
||||||
|
Ok(ExportResult { json: out, warnings: doc.ex.warnings })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package an export as a dotLottie archive (`manifest.json` + `animations/<id>.json`).
|
||||||
|
pub fn to_dotlottie(res: &ExportResult, id: &str) -> Vec<u8> {
|
||||||
|
let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
|
||||||
|
let id = if id.is_empty() { "animation".to_string() } else { id };
|
||||||
|
let manifest = json!({"version": "1", "generator": "EffectCraft", "author": "", "animations": [{"id": id, "speed": 1, "loop": true}]});
|
||||||
|
crate::zip::store(&[
|
||||||
|
("manifest.json".into(), serde_json::to_vec(&manifest).unwrap_or_default()),
|
||||||
|
(format!("animations/{id}.json"), serde_json::to_vec(&res.json).unwrap_or_default()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
//! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use effectcraft_color::Label;
|
||||||
|
use effectcraft_keyframe::{ShapePath, Value};
|
||||||
|
use effectcraft_project::build::{self, Ids};
|
||||||
|
use effectcraft_project::{
|
||||||
|
AlphaMode, AutoOrient, Comp, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MatteKind, Node, ParamUi, Project,
|
||||||
|
PropGroup, Property, Solid, TrackMatte,
|
||||||
|
};
|
||||||
|
use effectcraft_time::{FrameRate, Tick};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
|
||||||
|
use crate::anim::{self, Im, import_prop};
|
||||||
|
use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from};
|
||||||
|
use crate::{LottieError, Timebase, shapes, text};
|
||||||
|
|
||||||
|
/// An embedded image asset handed to the caller to store (the returned path becomes the
|
||||||
|
/// footage item's file).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ImportedImage {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
/// File extension from the data URI (`png`, `jpg`, …).
|
||||||
|
pub ext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ImportResult {
|
||||||
|
/// The new top-level composition.
|
||||||
|
pub comp: ItemId,
|
||||||
|
/// Every item created (comps, solids, images, folder).
|
||||||
|
pub items: Vec<ItemId>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f(j: &Json, k: &str) -> Option<f64> {
|
||||||
|
j.get(k).and_then(Json::as_f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> {
|
||||||
|
j.get(k).and_then(Json::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_color(sc: &str) -> [f32; 3] {
|
||||||
|
let h = sc.trim_start_matches('#');
|
||||||
|
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0;
|
||||||
|
[c(0), c(2), c(4)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Lottie JSON inside raw bytes (plain JSON, or a stored dotLottie archive).
|
||||||
|
fn document(bytes: &[u8]) -> Result<Json, LottieError> {
|
||||||
|
if bytes.starts_with(b"PK") {
|
||||||
|
let entries = crate::zip::read_stored(bytes);
|
||||||
|
let anim = entries
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| n.starts_with("animations/") && n.ends_with(".json"))
|
||||||
|
.or_else(|| entries.iter().find(|(n, _)| n.ends_with(".json") && n != "manifest.json"))
|
||||||
|
.ok_or_else(|| LottieError::Parse("dotLottie archive without an uncompressed animation".into()))?;
|
||||||
|
return serde_json::from_slice(&anim.1).map_err(|e| LottieError::Parse(e.to_string()));
|
||||||
|
}
|
||||||
|
serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ctx<'a> {
|
||||||
|
im: Im,
|
||||||
|
assets: BTreeMap<String, (ItemId, Option<(u32, u32)>)>,
|
||||||
|
fonts: Vec<(String, String, String)>,
|
||||||
|
store: &'a mut dyn FnMut(&ImportedImage) -> Option<String>,
|
||||||
|
base_dir: &'a str,
|
||||||
|
items: Vec<ItemId>,
|
||||||
|
folder: Option<ItemId>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx<'_> {
|
||||||
|
fn folder(&mut self, project: &mut Project) -> ItemId {
|
||||||
|
if let Some(f) = self.folder {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
let f = project.add_item(&format!("{} Assets", self.name), Label::None, None, ItemKind::Folder);
|
||||||
|
self.items.push(f);
|
||||||
|
self.folder = Some(f);
|
||||||
|
f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a Lottie document (JSON or a stored `.lottie` archive) into `project` as a new
|
||||||
|
/// composition. `store` saves embedded images and returns the file path to use;
|
||||||
|
/// `base_dir` resolves external image references.
|
||||||
|
pub fn import(
|
||||||
|
project: &mut Project,
|
||||||
|
bytes: &[u8],
|
||||||
|
fallback_name: &str,
|
||||||
|
base_dir: &str,
|
||||||
|
store: &mut dyn FnMut(&ImportedImage) -> Option<String>,
|
||||||
|
) -> Result<ImportResult, LottieError> {
|
||||||
|
let doc = document(bytes)?;
|
||||||
|
let (Some(w), Some(h)) = (f(&doc, "w"), f(&doc, "h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) };
|
||||||
|
let fr = f(&doc, "fr").filter(|v| *v > 0.0).unwrap_or(30.0);
|
||||||
|
let rate = FrameRate::from_f64(fr);
|
||||||
|
let tb = Timebase { rate };
|
||||||
|
let name = s(&doc, "nm").filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string();
|
||||||
|
let ip = f(&doc, "ip").unwrap_or(0.0);
|
||||||
|
let op = f(&doc, "op").unwrap_or(ip + fr).max(ip + 1.0);
|
||||||
|
let mut cx =
|
||||||
|
Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() };
|
||||||
|
if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) {
|
||||||
|
for fo in list {
|
||||||
|
let n = s(fo, "fName").unwrap_or("").to_string();
|
||||||
|
let fam = s(fo, "fFamily").unwrap_or(&n).to_string();
|
||||||
|
let st = s(fo, "fStyle").unwrap_or("Regular").to_string();
|
||||||
|
cx.fonts.push((n, fam, st));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
|
||||||
|
let duration = tb.tick(op);
|
||||||
|
// Pass 1: asset items (precomps empty for now, images stored).
|
||||||
|
let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||||
|
for a in &assets {
|
||||||
|
let Some(id) = s(a, "id") else { continue };
|
||||||
|
if a.get("layers").is_some() {
|
||||||
|
let folder = cx.folder(project);
|
||||||
|
let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h));
|
||||||
|
let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
|
||||||
|
let c = Comp::new(aw, ah, arate, duration);
|
||||||
|
let nm = s(a, "nm").unwrap_or(id).to_string();
|
||||||
|
let item = project.add_item(&nm, Label::Sandstone, Some(folder), ItemKind::Comp(Arc::new(c)));
|
||||||
|
cx.items.push(item);
|
||||||
|
cx.assets.insert(id.to_string(), (item, f(a, "w").zip(f(a, "h")).map(|(x, y)| (x as u32, y as u32))));
|
||||||
|
} else if let Some(p) = s(a, "p") {
|
||||||
|
let (aw, ah) = (f(a, "w").unwrap_or(0.0) as u32, f(a, "h").unwrap_or(0.0) as u32);
|
||||||
|
let nm = s(a, "nm").unwrap_or(id).to_string();
|
||||||
|
let path = if let Some((mime, data)) = crate::base64::data_uri(p) {
|
||||||
|
let ext = match mime.as_str() {
|
||||||
|
"image/jpeg" | "image/jpg" => "jpg",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/svg+xml" => "svg",
|
||||||
|
_ => "png",
|
||||||
|
};
|
||||||
|
(cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() })
|
||||||
|
} else {
|
||||||
|
let u = s(a, "u").unwrap_or("");
|
||||||
|
let rel = format!("{u}{p}");
|
||||||
|
Some(if rel.starts_with('/') || cx.base_dir.is_empty() { rel } else { format!("{}/{rel}", cx.base_dir.trim_end_matches('/')) })
|
||||||
|
};
|
||||||
|
let Some(path) = path else {
|
||||||
|
cx.im.warn(format!("image asset `{id}` could not be stored"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let folder = cx.folder(project);
|
||||||
|
let foot = Footage {
|
||||||
|
path,
|
||||||
|
kind: FootageKind::Still,
|
||||||
|
width: aw.max(1),
|
||||||
|
height: ah.max(1),
|
||||||
|
pixel_aspect: 1.0,
|
||||||
|
frame_rate: rate,
|
||||||
|
native_rate: None,
|
||||||
|
duration: Tick::ZERO,
|
||||||
|
has_video: true,
|
||||||
|
has_audio: false,
|
||||||
|
alpha: AlphaMode::Straight,
|
||||||
|
premul_color: [0.0; 3],
|
||||||
|
loop_count: 1,
|
||||||
|
codec: String::new(),
|
||||||
|
missing: false,
|
||||||
|
sequence: vec![],
|
||||||
|
};
|
||||||
|
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
|
||||||
|
cx.items.push(item);
|
||||||
|
cx.assets.insert(id.to_string(), (item, Some((aw, ah))));
|
||||||
|
} else {
|
||||||
|
cx.im.warn(format!("asset `{id}` is not supported (skipped)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass 2: precomp contents.
|
||||||
|
for a in &assets {
|
||||||
|
let (Some(id), Some(layers)) = (s(a, "id"), a.get("layers").and_then(Json::as_array)) else { continue };
|
||||||
|
let Some((item, _)) = cx.assets.get(id).copied() else { continue };
|
||||||
|
let Some(mut c) = project.comp(item).cloned() else { continue };
|
||||||
|
let end = layers.iter().filter_map(|l| f(l, "op")).fold(0.0f64, f64::max);
|
||||||
|
if end > 0.0 {
|
||||||
|
c.duration = tb.tick(end);
|
||||||
|
c.work_area = (Tick::ZERO, c.duration);
|
||||||
|
}
|
||||||
|
build_layers(&mut cx, project, &mut c, layers);
|
||||||
|
if let Some(it) = project.item_mut(item) {
|
||||||
|
it.kind = ItemKind::Comp(Arc::new(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The main comp.
|
||||||
|
let mut comp = Comp::new(w, h, rate, duration);
|
||||||
|
if ip > 0.0 {
|
||||||
|
comp.work_area = (tb.tick(ip), duration);
|
||||||
|
}
|
||||||
|
if let Some(ms) = doc.get("markers").and_then(Json::as_array) {
|
||||||
|
for m in ms {
|
||||||
|
comp.markers.push(Marker {
|
||||||
|
time: tb.tick(f(m, "tm").unwrap_or(0.0)),
|
||||||
|
duration: tb.tick(f(m, "dr").unwrap_or(0.0)),
|
||||||
|
comment: s(m, "cm").unwrap_or("").to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let layers = doc.get("layers").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||||
|
build_layers(&mut cx, project, &mut comp, &layers);
|
||||||
|
let id = project.add_item(&name, Label::Sandstone, None, ItemKind::Comp(Arc::new(comp)));
|
||||||
|
cx.items.push(id);
|
||||||
|
project.fix_next_id();
|
||||||
|
Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
|
||||||
|
let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option<Value>| {
|
||||||
|
if let (Some(j), Some(p)) = (j, tr.get_mut(m)) {
|
||||||
|
import_prop(im, j, p, conv);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(tr, "anchor", ks.get("a"), &|v| anim::to_vec3(v, 0.0));
|
||||||
|
set(tr, "scale", ks.get("s"), &|v| anim::to_vec3(v, 100.0));
|
||||||
|
set(tr, "rotation", ks.get("r").or(ks.get("rz")), &anim::to_scalar);
|
||||||
|
set(tr, "rotationX", ks.get("rx"), &anim::to_scalar);
|
||||||
|
set(tr, "rotationY", ks.get("ry"), &anim::to_scalar);
|
||||||
|
set(tr, "orientation", ks.get("or"), &|v| anim::to_vec3(v, 0.0));
|
||||||
|
set(tr, "opacity", ks.get("o"), &anim::to_scalar);
|
||||||
|
let Some(p) = ks.get("p") else { return };
|
||||||
|
if p.get("s").and_then(Json::as_bool) == Some(true) {
|
||||||
|
// Separated dimensions: X/Y/Z Position after Position.
|
||||||
|
let at = tr.children.iter().position(|c| c.match_id() == "position").map(|i| i + 1).unwrap_or(tr.children.len());
|
||||||
|
let base = tr.get("position").map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
|
||||||
|
for (d, (key, m, name)) in
|
||||||
|
[("x", "positionX", "X Position"), ("y", "positionY", "Y Position"), ("z", "positionZ", "Z Position")].iter().enumerate().rev()
|
||||||
|
{
|
||||||
|
let mut pr = Property::new(*next, m, name, Value::Scalar(base[d])).with_ui(ParamUi::Number);
|
||||||
|
*next += 1;
|
||||||
|
pr.three_d_only = d == 2;
|
||||||
|
if let Some(j) = p.get(*key) {
|
||||||
|
import_prop(im, j, &mut pr, &anim::to_scalar);
|
||||||
|
}
|
||||||
|
tr.children.insert(at, Node::Prop(pr));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set(tr, "position", Some(p), &|v| anim::to_vec3(v, 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
|
||||||
|
for (i, m) in list.iter().enumerate() {
|
||||||
|
let mode = mask_mode_from(s(m, "mode").unwrap_or("a"));
|
||||||
|
let name = s(m, "nm").map(str::to_string).unwrap_or(format!("Mask {}", i + 1));
|
||||||
|
let color = build::MASK_COLORS[i % build::MASK_COLORS.len()];
|
||||||
|
let mut mg = build::mask(ids, &name, ShapePath::default(), mode, color);
|
||||||
|
if let GroupKind::Mask { inverted, .. } = &mut mg.kind {
|
||||||
|
*inverted = m.get("inv").and_then(Json::as_bool).unwrap_or(false);
|
||||||
|
}
|
||||||
|
let mut set = |k: &str, mid: &str, conv: &dyn Fn(&Json) -> Option<Value>| {
|
||||||
|
if let (Some(j), Some(p)) = (m.get(k), mg.get_mut(mid)) {
|
||||||
|
import_prop(im, j, p, conv);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set("pt", "path", &shapes::to_path);
|
||||||
|
set("o", "opacity", &anim::to_scalar);
|
||||||
|
set("x", "expansion", &anim::to_scalar);
|
||||||
|
set("f", "feather", &anim::to_vec2);
|
||||||
|
g.children.push(Node::Group(mg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [f64; 2]) {
|
||||||
|
for e in list {
|
||||||
|
let ty = e.get("ty").and_then(Json::as_u64).unwrap_or(0);
|
||||||
|
let params = e.get("ef").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||||
|
let inner_ty = params.first().and_then(|p| p.get("ty")).and_then(Json::as_u64);
|
||||||
|
let map = s(e, "mn").and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty));
|
||||||
|
let nm = s(e, "nm").unwrap_or("Effect");
|
||||||
|
let Some(map) = map else {
|
||||||
|
im.warn(format!("effect `{nm}` (type {ty}) is not supported (skipped)"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(spec) = effectcraft_effects::find(map.id) else { continue };
|
||||||
|
let mut fg = effectcraft_effects::instantiate(spec, ids, nm, size);
|
||||||
|
if e.get("en").and_then(Json::as_u64) == Some(0) {
|
||||||
|
fg.enabled = false;
|
||||||
|
}
|
||||||
|
for (i, (m, pty, scale, _)) in fx_params(map).iter().enumerate() {
|
||||||
|
if m.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pj) = params.get(i).and_then(|p| p.get("v")) else { continue };
|
||||||
|
let Some(p) = fg.get_mut(m) else { continue };
|
||||||
|
let (pty, scale) = (*pty, *scale);
|
||||||
|
let conv = move |v: &Json| -> Option<Value> {
|
||||||
|
match pty {
|
||||||
|
2 => anim::to_color(v),
|
||||||
|
3 => anim::to_vec2(v),
|
||||||
|
4 => anim::to_bool(v),
|
||||||
|
7 => anim::to_enum1(v),
|
||||||
|
_ => anim::to_scalar(v).map(|x| Value::Scalar(x.as_f64() / scale)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
import_prop(im, pj, p, &conv);
|
||||||
|
}
|
||||||
|
g.children.push(Node::Group(fg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> Option<(LayerSource, (u32, u32))> {
|
||||||
|
let ty = j.get("ty").and_then(Json::as_u64).unwrap_or(99);
|
||||||
|
let full = (comp.width, comp.height);
|
||||||
|
let asset = |cx: &Ctx| s(j, "refId").and_then(|r| cx.assets.get(r).copied());
|
||||||
|
Some(match ty {
|
||||||
|
0 => {
|
||||||
|
let Some((item, _)) = asset(cx) else {
|
||||||
|
cx.im.warn(format!("{}: missing precomp asset", s(j, "nm").unwrap_or("layer")));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let size = project.comp(item).map(|c| (c.width, c.height)).unwrap_or(full);
|
||||||
|
(LayerSource::Comp { item }, size)
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let sw = f(j, "sw").unwrap_or(comp.width as f64).round().max(1.0) as u32;
|
||||||
|
let sh = f(j, "sh").unwrap_or(comp.height as f64).round().max(1.0) as u32;
|
||||||
|
let color = hex_color(s(j, "sc").unwrap_or("#000000"));
|
||||||
|
let name = s(j, "nm").unwrap_or("Solid");
|
||||||
|
let folder = cx.folder(project);
|
||||||
|
let item = project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: sw, height: sh, pixel_aspect: 1.0 }));
|
||||||
|
cx.items.push(item);
|
||||||
|
(LayerSource::Solid { item }, (sw, sh))
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let Some((item, size)) = asset(cx) else {
|
||||||
|
cx.im.warn(format!("{}: missing image asset", s(j, "nm").unwrap_or("layer")));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
(LayerSource::Footage { item }, size.unwrap_or(full))
|
||||||
|
}
|
||||||
|
3 => (LayerSource::Null, (100, 100)),
|
||||||
|
4 => (LayerSource::Shape, full),
|
||||||
|
5 => (LayerSource::Text, full),
|
||||||
|
13 => {
|
||||||
|
cx.im.warn(format!("{}: camera layers are not imported", s(j, "nm").unwrap_or("camera")));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", s(j, "nm").unwrap_or("layer")));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the layers of a comp (Lottie order: first = top = layer #1).
|
||||||
|
fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) {
|
||||||
|
let tb = cx.im.tb;
|
||||||
|
let mut by_ind: BTreeMap<i64, LayerId> = BTreeMap::new();
|
||||||
|
// (layer id, parent ind, matte: (kind, explicit tp ind), json position)
|
||||||
|
let mut pending: Vec<(LayerId, Option<i64>, Option<(MatteKind, Option<i64>)>, usize)> = vec![];
|
||||||
|
let mut pos_ind: Vec<Option<i64>> = vec![];
|
||||||
|
for (ji, j) in layers.iter().enumerate() {
|
||||||
|
let ind = j.get("ind").and_then(Json::as_i64);
|
||||||
|
pos_ind.push(ind);
|
||||||
|
let Some((source, size)) = layer_source(cx, project, comp, j) else { continue };
|
||||||
|
let name = s(j, "nm").unwrap_or(source.type_name()).to_string();
|
||||||
|
let mut l: Layer = build::layer(project, comp, &name, source.clone(), size, None);
|
||||||
|
if let Some(i) = ind {
|
||||||
|
by_ind.insert(i, l.id);
|
||||||
|
}
|
||||||
|
let ip = f(j, "ip").unwrap_or(0.0);
|
||||||
|
let op = f(j, "op").unwrap_or(tb.frame(comp.duration));
|
||||||
|
l.in_point = tb.tick(ip);
|
||||||
|
l.out_point = tb.tick(op.max(ip));
|
||||||
|
l.start_time = tb.tick(f(j, "st").unwrap_or(0.0));
|
||||||
|
l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0;
|
||||||
|
if j.get("ddd").and_then(Json::as_u64) == Some(1) {
|
||||||
|
l.switches.three_d = true;
|
||||||
|
}
|
||||||
|
if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
|
||||||
|
l.blend_mode = crate::blend_from_lottie(bm);
|
||||||
|
}
|
||||||
|
if j.get("ao").and_then(Json::as_u64) == Some(1) {
|
||||||
|
l.auto_orient = AutoOrient::AlongPath;
|
||||||
|
}
|
||||||
|
if j.get("hd").and_then(Json::as_bool) == Some(true) {
|
||||||
|
l.switches.video = false;
|
||||||
|
}
|
||||||
|
if j.get("td").and_then(Json::as_u64) == Some(1) {
|
||||||
|
l.switches.video = false;
|
||||||
|
}
|
||||||
|
let mut next = project.next_id;
|
||||||
|
if let (Some(ks), Some(tr)) = (j.get("ks"), l.props.sub_mut("transform")) {
|
||||||
|
transform(&cx.im, ks, tr, &mut next);
|
||||||
|
}
|
||||||
|
let mut ids = Ids(&mut next);
|
||||||
|
match &source {
|
||||||
|
LayerSource::Shape => {
|
||||||
|
let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||||
|
let groups = shapes::import_items(&mut cx.im, &mut ids, &items);
|
||||||
|
if let Some(c) = l.props.sub_mut("contents") {
|
||||||
|
c.children.extend(groups.into_iter().map(Node::Group));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Text => {
|
||||||
|
if let (Some(t), Some(tg)) = (j.get("t"), l.props.sub_mut("text")) {
|
||||||
|
text::import_text(&mut cx.im, &mut ids, t, tg, &cx.fonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LayerSource::Comp { .. } => {
|
||||||
|
if let Some(tm) = j.get("tm") {
|
||||||
|
let mut pr = ids.prop("timeRemap", "Time Remap", Value::Scalar(0.0)).with_ui(ParamUi::Number);
|
||||||
|
import_prop(&cx.im, tm, &mut pr, &anim::to_scalar);
|
||||||
|
l.props.children.insert(0, Node::Prop(pr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(list) = j.get("masksProperties").and_then(Json::as_array)
|
||||||
|
&& let Some(g) = l.props.sub_mut("masks")
|
||||||
|
{
|
||||||
|
masks(&cx.im, &mut ids, list, g);
|
||||||
|
}
|
||||||
|
if let Some(list) = j.get("ef").and_then(Json::as_array)
|
||||||
|
&& let Some(g) = l.props.sub_mut("effects")
|
||||||
|
{
|
||||||
|
effects(&mut cx.im, &mut ids, list, g, [size.0 as f64, size.1 as f64]);
|
||||||
|
}
|
||||||
|
if j.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) {
|
||||||
|
cx.im.warn(format!("{name}: layer styles are not imported"));
|
||||||
|
}
|
||||||
|
project.next_id = next;
|
||||||
|
let parent = j.get("parent").and_then(Json::as_i64);
|
||||||
|
let tt = j.get("tt").and_then(Json::as_u64).unwrap_or(0);
|
||||||
|
let matte = match tt {
|
||||||
|
1 => Some(MatteKind::Alpha),
|
||||||
|
2 => Some(MatteKind::AlphaInverted),
|
||||||
|
3 => Some(MatteKind::Luma),
|
||||||
|
4 => Some(MatteKind::LumaInverted),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.map(|k| (k, j.get("tp").and_then(Json::as_i64)));
|
||||||
|
pending.push((l.id, parent, matte, ji));
|
||||||
|
comp.layers.push(l);
|
||||||
|
}
|
||||||
|
for (id, parent, matte, ji) in pending {
|
||||||
|
let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id);
|
||||||
|
let matte_id = matte.and_then(|(k, tp)| {
|
||||||
|
let target = match tp {
|
||||||
|
Some(tp) => by_ind.get(&tp).copied(),
|
||||||
|
// Legacy files: the matte is the layer directly above.
|
||||||
|
None => ji.checked_sub(1).and_then(|a| pos_ind.get(a).copied().flatten()).and_then(|i| by_ind.get(&i).copied()),
|
||||||
|
};
|
||||||
|
target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k })
|
||||||
|
});
|
||||||
|
if let Some(l) = comp.layer_mut(id) {
|
||||||
|
l.parent = parent_id;
|
||||||
|
l.track_matte = matte_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
//! Lottie JSON import and export.
|
||||||
|
//!
|
||||||
|
//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the
|
||||||
|
//! LottieFiles format documentation); no player or exporter source was used.
|
||||||
|
//!
|
||||||
|
//! **Export** ([`export_comp`]) turns a composition into a Lottie document: nested compositions
|
||||||
|
//! become precomp assets, still footage becomes embedded base64 image assets, and shape, solid,
|
||||||
|
//! null, precomp, image and text layers map to their Lottie layer types (text optionally as glyph
|
||||||
|
//! shapes). Transforms (with parenting, separated position and anchor), keyframes (temporal ease
|
||||||
|
//! converted from speed/influence to normalised Bezier `i`/`o` tangents, hold keys, spatial
|
||||||
|
//! `ti`/`to` tangents), shape contents, masks, track mattes, blend modes, time remapping, layer
|
||||||
|
//! timing and composition markers are exported. Anything Lottie cannot express (most effects,
|
||||||
|
//! cameras and lights, layer styles, audio, video footage…) is listed in
|
||||||
|
//! [`ExportResult::warnings`].
|
||||||
|
//!
|
||||||
|
//! **Import** ([`import`]) is the inverse mapping into a new composition.
|
||||||
|
//!
|
||||||
|
//! Keyframe times in Lottie are frames of the layer's local time, which is EffectCraft's layer
|
||||||
|
//! time; layer `ip`/`op`/`st` are composition frames and `sr` is the stretch factor.
|
||||||
|
|
||||||
|
mod anim;
|
||||||
|
mod base64;
|
||||||
|
mod export;
|
||||||
|
mod import;
|
||||||
|
mod shapes;
|
||||||
|
mod text;
|
||||||
|
mod zip;
|
||||||
|
|
||||||
|
use effectcraft_color::BlendMode;
|
||||||
|
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||||
|
|
||||||
|
pub use export::{ExportOptions, ExportResult, export_comp, to_dotlottie};
|
||||||
|
pub use import::{ImportResult, ImportedImage, import};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum LottieError {
|
||||||
|
#[error("not a Lottie document: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame ↔ tick conversion at a frame rate.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct Timebase {
|
||||||
|
pub rate: FrameRate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timebase {
|
||||||
|
/// Frame number (fractional) of a time.
|
||||||
|
pub fn frame(&self, t: Tick) -> f64 {
|
||||||
|
let f = t.0 as f64 * self.rate.num as f64 / (self.rate.den as f64 * TICKS_PER_SECOND as f64);
|
||||||
|
// Keep exact frame numbers clean (no 23.999999).
|
||||||
|
if (f - f.round()).abs() < 1e-6 { f.round() } else { f }
|
||||||
|
}
|
||||||
|
pub fn tick(&self, f: f64) -> Tick {
|
||||||
|
Tick((f * TICKS_PER_SECOND as f64 * self.rate.den as f64 / self.rate.num as f64).round() as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lottie layer / shape blend modes (`bm`) in order.
|
||||||
|
const LOTTIE_BLEND: [BlendMode; 18] = [
|
||||||
|
BlendMode::Normal,
|
||||||
|
BlendMode::Multiply,
|
||||||
|
BlendMode::Screen,
|
||||||
|
BlendMode::Overlay,
|
||||||
|
BlendMode::Darken,
|
||||||
|
BlendMode::Lighten,
|
||||||
|
BlendMode::ColorDodge,
|
||||||
|
BlendMode::ColorBurn,
|
||||||
|
BlendMode::HardLight,
|
||||||
|
BlendMode::SoftLight,
|
||||||
|
BlendMode::Difference,
|
||||||
|
BlendMode::Exclusion,
|
||||||
|
BlendMode::Hue,
|
||||||
|
BlendMode::Saturation,
|
||||||
|
BlendMode::Color,
|
||||||
|
BlendMode::Luminosity,
|
||||||
|
BlendMode::Add,
|
||||||
|
BlendMode::HardMix,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Lottie `bm` of a blend mode (`None` = Lottie has no equivalent).
|
||||||
|
pub(crate) fn blend_to_lottie(m: BlendMode) -> Option<u32> {
|
||||||
|
LOTTIE_BLEND.iter().position(|b| *b == m).map(|i| i as u32).or(match m {
|
||||||
|
BlendMode::LinearDodge => Some(16),
|
||||||
|
BlendMode::ClassicColorDodge => Some(6),
|
||||||
|
BlendMode::ClassicColorBurn => Some(7),
|
||||||
|
BlendMode::ClassicDifference => Some(10),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn blend_from_lottie(bm: u64) -> BlendMode {
|
||||||
|
LOTTIE_BLEND.get(bm as usize).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
@ -0,0 +1,578 @@
|
||||||
|
//! Shape layer contents ↔ Lottie shape items (`gr`, `rc`, `el`, `sr`, `sh`, `fl`, `st`, `gf`,
|
||||||
|
//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`).
|
||||||
|
|
||||||
|
use effectcraft_color::BlendMode;
|
||||||
|
use effectcraft_keyframe::{Gradient, ShapePath, Value};
|
||||||
|
use effectcraft_project::build::{self, Ids};
|
||||||
|
use effectcraft_project::{Node, PropGroup};
|
||||||
|
use serde_json::{Map, Value as Json, json};
|
||||||
|
|
||||||
|
use crate::anim::{self, Ex, Im, export_prop, import_prop};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- shared value converters
|
||||||
|
|
||||||
|
pub(crate) fn shape_json(p: &ShapePath) -> Json {
|
||||||
|
let pts = |v: &[[f64; 2]]| v.iter().map(|q| json!([r(q[0]), r(q[1])])).collect::<Vec<_>>();
|
||||||
|
json!({"c": p.closed, "v": pts(&p.vertices), "i": pts(&p.in_tangents), "o": pts(&p.out_tangents)})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn r(v: f64) -> f64 {
|
||||||
|
(v * 1e6).round() / 1e6
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn path_value(v: &Value) -> Json {
|
||||||
|
match v {
|
||||||
|
Value::Path(p) => shape_json(p),
|
||||||
|
_ => shape_json(&ShapePath::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_path(j: &Json) -> Option<Value> {
|
||||||
|
let o = match j {
|
||||||
|
Json::Array(a) => a.first()?,
|
||||||
|
o => o,
|
||||||
|
};
|
||||||
|
let pts = |k: &str| -> Vec<[f64; 2]> {
|
||||||
|
o.get(k)
|
||||||
|
.and_then(Json::as_array)
|
||||||
|
.map(|a| a.iter().map(|p| anim::nums(p).map(|v| [*v.first().unwrap_or(&0.0), *v.get(1).unwrap_or(&0.0)]).unwrap_or([0.0; 2])).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let vertices = pts("v");
|
||||||
|
let n = vertices.len();
|
||||||
|
let fit = |mut v: Vec<[f64; 2]>| {
|
||||||
|
v.resize(n, [0.0; 2]);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
Some(Value::Path(ShapePath {
|
||||||
|
in_tangents: fit(pts("i")),
|
||||||
|
out_tangents: fit(pts("o")),
|
||||||
|
vertices,
|
||||||
|
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gradient as Lottie's flat stop list: `[pos, r, g, b]…` then `[pos, alpha]…`.
|
||||||
|
pub(crate) fn gradient_flat(v: &Value) -> Json {
|
||||||
|
let g = match v {
|
||||||
|
Value::Gradient(g) => g.clone(),
|
||||||
|
_ => Gradient::default(),
|
||||||
|
};
|
||||||
|
let mut out = vec![];
|
||||||
|
for (p, c) in &g.colors {
|
||||||
|
out.extend([r(*p), r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)]);
|
||||||
|
}
|
||||||
|
let plain = g.opacities.iter().all(|(_, a)| (*a - 1.0).abs() < 1e-6);
|
||||||
|
if !plain {
|
||||||
|
for (p, a) in &g.opacities {
|
||||||
|
out.extend([r(*p), r(*a as f64)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_gradient(j: &Json, ncolors: usize) -> Option<Value> {
|
||||||
|
let v = anim::nums(j)?;
|
||||||
|
let n = ncolors.min(v.len() / 4);
|
||||||
|
let colors: Vec<(f64, [f32; 4])> = (0..n).map(|i| (v[i * 4], [v[i * 4 + 1] as f32, v[i * 4 + 2] as f32, v[i * 4 + 3] as f32, 1.0])).collect();
|
||||||
|
let rest = &v[n * 4..];
|
||||||
|
let mut opacities: Vec<(f64, f32)> = rest.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1] as f32)).collect();
|
||||||
|
if opacities.is_empty() {
|
||||||
|
opacities = vec![(0.0, 1.0), (1.0, 1.0)];
|
||||||
|
}
|
||||||
|
Some(Value::Gradient(Gradient { colors, opacities }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blend_index(m: BlendMode) -> u32 {
|
||||||
|
BlendMode::ALL.iter().position(|b| *b == m).unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- export
|
||||||
|
|
||||||
|
struct Out<'a, 'b> {
|
||||||
|
ex: &'a mut Ex,
|
||||||
|
g: &'b PropGroup,
|
||||||
|
o: Map<String, Json>,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Out<'_, '_> {
|
||||||
|
fn put(&mut self, key: &str, m: &str, conv: &dyn Fn(&Value) -> Json) {
|
||||||
|
if let Some(p) = self.g.get(m) {
|
||||||
|
let v = export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name));
|
||||||
|
self.o.insert(key.into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn raw(&mut self, key: &str, v: Json) {
|
||||||
|
self.o.insert(key.into(), v);
|
||||||
|
}
|
||||||
|
fn e(&self, m: &str) -> u32 {
|
||||||
|
self.g.get(m).map(|p| p.value.as_enum()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn f(&self, m: &str) -> f64 {
|
||||||
|
self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
fn animated(&self, m: &str) -> bool {
|
||||||
|
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
|
||||||
|
}
|
||||||
|
fn blend(&mut self) {
|
||||||
|
let i = self.e("blend");
|
||||||
|
let m = BlendMode::ALL.get(i as usize).copied().unwrap_or_default();
|
||||||
|
match crate::blend_to_lottie(m) {
|
||||||
|
Some(bm) => self.raw("bm", json!(bm)),
|
||||||
|
None => {
|
||||||
|
self.ex.warn(format!("{}: blend mode {m:?} has no Lottie equivalent (Normal used)", self.label));
|
||||||
|
self.raw("bm", json!(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn done(self) -> Json {
|
||||||
|
Json::Object(self.o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke fields shared by `st` and `gs`.
|
||||||
|
fn stroke_fields(o: &mut Out) {
|
||||||
|
o.put("w", "width", &anim::num);
|
||||||
|
o.raw("lc", json!(o.e("cap") + 1));
|
||||||
|
o.raw("lj", json!(o.e("join") + 1));
|
||||||
|
o.raw("ml", json!(o.f("miter")));
|
||||||
|
if let Some(d) = o.g.sub("dashes") {
|
||||||
|
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
|
||||||
|
if dash > 0.0 || d.any_animated() {
|
||||||
|
let mut list = vec![];
|
||||||
|
for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] {
|
||||||
|
if let Some(p) = d.get(m) {
|
||||||
|
let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name));
|
||||||
|
list.push(json!({"n": n, "nm": p.name, "v": v}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.raw("d", json!(list));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Json {
|
||||||
|
let mut o = Out { ex, g: tr, o: Map::new(), label: format!("{label} ▸ Transform") };
|
||||||
|
o.raw("ty", json!("tr"));
|
||||||
|
o.put("a", "anchor", &anim::arr2);
|
||||||
|
o.put("p", "position", &anim::arr2);
|
||||||
|
o.put("s", "scale", &anim::arr2);
|
||||||
|
o.put("r", "rotation", &anim::num);
|
||||||
|
o.put("o", "opacity", &anim::num);
|
||||||
|
o.put("sk", "skew", &anim::num);
|
||||||
|
o.put("sa", "skewAxis", &anim::num);
|
||||||
|
o.put("so", "startOpacity", &anim::num);
|
||||||
|
o.put("eo", "endOpacity", &anim::num);
|
||||||
|
o.done()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export a contents group's items (first item = top, as in the timeline).
|
||||||
|
pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec<Json> {
|
||||||
|
let mut out = vec![];
|
||||||
|
for node in &contents.children {
|
||||||
|
let Node::Group(g) = node else { continue };
|
||||||
|
let lbl = format!("{label} ▸ {}", g.name);
|
||||||
|
let ty = match g.match_id.as_str() {
|
||||||
|
"group" => "gr",
|
||||||
|
"rect" => "rc",
|
||||||
|
"ellipse" => "el",
|
||||||
|
"star" => "sr",
|
||||||
|
"path" => "sh",
|
||||||
|
"fill" => "fl",
|
||||||
|
"stroke" => "st",
|
||||||
|
"gfill" => "gf",
|
||||||
|
"gstroke" => "gs",
|
||||||
|
"trim" => "tm",
|
||||||
|
"repeater" => "rp",
|
||||||
|
"round" => "rd",
|
||||||
|
"offset" => "op",
|
||||||
|
"pucker" => "pb",
|
||||||
|
"twist" => "tw",
|
||||||
|
"zigzag" => "zz",
|
||||||
|
"merge" => "mm",
|
||||||
|
other => {
|
||||||
|
ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut o = Out { ex, g, o: Map::new(), label: lbl.clone() };
|
||||||
|
o.raw("ty", json!(ty));
|
||||||
|
o.raw("nm", json!(g.name));
|
||||||
|
if !g.enabled {
|
||||||
|
o.raw("hd", json!(true));
|
||||||
|
}
|
||||||
|
let dir = |o: &mut Out| {
|
||||||
|
let d = if o.e("direction") == 1 { 3 } else { 1 };
|
||||||
|
o.raw("d", json!(d));
|
||||||
|
};
|
||||||
|
match ty {
|
||||||
|
"gr" => {
|
||||||
|
let items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default();
|
||||||
|
let mut items = items;
|
||||||
|
let tr = match g.sub("transform") {
|
||||||
|
Some(t) => shape_transform(o.ex, t, &lbl),
|
||||||
|
None => {
|
||||||
|
json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
items.push(tr);
|
||||||
|
o.raw("np", json!(items.len()));
|
||||||
|
o.raw("it", json!(items));
|
||||||
|
o.blend();
|
||||||
|
}
|
||||||
|
"rc" => {
|
||||||
|
dir(&mut o);
|
||||||
|
o.put("s", "size", &anim::arr2);
|
||||||
|
o.put("p", "position", &anim::arr2);
|
||||||
|
o.put("r", "roundness", &anim::num);
|
||||||
|
}
|
||||||
|
"el" => {
|
||||||
|
dir(&mut o);
|
||||||
|
o.put("s", "size", &anim::arr2);
|
||||||
|
o.put("p", "position", &anim::arr2);
|
||||||
|
}
|
||||||
|
"sr" => {
|
||||||
|
dir(&mut o);
|
||||||
|
o.raw("sy", json!(o.e("type") + 1));
|
||||||
|
o.put("pt", "points", &anim::num);
|
||||||
|
o.put("p", "position", &anim::arr2);
|
||||||
|
o.put("r", "rotation", &anim::num);
|
||||||
|
o.put("ir", "innerRadius", &anim::num);
|
||||||
|
o.put("or", "outerRadius", &anim::num);
|
||||||
|
o.put("is", "innerRoundness", &anim::num);
|
||||||
|
o.put("os", "outerRoundness", &anim::num);
|
||||||
|
}
|
||||||
|
"sh" => {
|
||||||
|
dir(&mut o);
|
||||||
|
o.put("ks", "path", &path_value);
|
||||||
|
}
|
||||||
|
"fl" => {
|
||||||
|
o.put("c", "color", &anim::arr);
|
||||||
|
o.put("o", "opacity", &anim::num);
|
||||||
|
o.raw("r", json!(o.e("rule") + 1));
|
||||||
|
o.blend();
|
||||||
|
}
|
||||||
|
"st" => {
|
||||||
|
o.put("c", "color", &anim::arr);
|
||||||
|
o.put("o", "opacity", &anim::num);
|
||||||
|
stroke_fields(&mut o);
|
||||||
|
o.blend();
|
||||||
|
}
|
||||||
|
"gf" | "gs" => {
|
||||||
|
o.put("o", "opacity", &anim::num);
|
||||||
|
o.put("s", "start", &anim::arr2);
|
||||||
|
o.put("e", "end", &anim::arr2);
|
||||||
|
o.raw("t", json!(o.e("type") + 1));
|
||||||
|
o.put("h", "highlightLength", &anim::num);
|
||||||
|
o.put("a", "highlightAngle", &anim::num);
|
||||||
|
let ncolors = match g.get("colors").map(|p| &p.value) {
|
||||||
|
Some(Value::Gradient(gr)) => gr.colors.len(),
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
if let Some(p) = g.get("colors") {
|
||||||
|
let k = export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors"));
|
||||||
|
o.raw("g", json!({"p": ncolors, "k": k}));
|
||||||
|
}
|
||||||
|
if ty == "gf" {
|
||||||
|
o.raw("r", json!(o.e("rule") + 1));
|
||||||
|
} else {
|
||||||
|
stroke_fields(&mut o);
|
||||||
|
}
|
||||||
|
o.blend();
|
||||||
|
}
|
||||||
|
"tm" => {
|
||||||
|
o.put("s", "start", &anim::num);
|
||||||
|
o.put("e", "end", &anim::num);
|
||||||
|
o.put("o", "offset", &anim::num);
|
||||||
|
o.raw("m", json!(o.e("mode") + 1));
|
||||||
|
}
|
||||||
|
"rp" => {
|
||||||
|
o.put("c", "copies", &anim::num);
|
||||||
|
o.put("o", "offset", &anim::num);
|
||||||
|
// Lottie: 1 = Above, 2 = Below; EffectCraft: 0 = Below, 1 = Above.
|
||||||
|
o.raw("m", json!(if o.e("composite") == 1 { 1 } else { 2 }));
|
||||||
|
if let Some(t) = g.sub("transform") {
|
||||||
|
let tr = shape_transform(o.ex, t, &lbl);
|
||||||
|
o.raw("tr", tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"rd" => o.put("r", "radius", &anim::num),
|
||||||
|
"op" => {
|
||||||
|
o.put("a", "amount", &anim::num);
|
||||||
|
o.raw("lj", json!(o.e("join") + 1));
|
||||||
|
o.raw("ml", json!(o.f("miter")));
|
||||||
|
if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 {
|
||||||
|
o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pb" => o.put("a", "amount", &anim::num),
|
||||||
|
"tw" => {
|
||||||
|
o.put("a", "angle", &anim::num);
|
||||||
|
o.put("c", "center", &anim::arr2);
|
||||||
|
}
|
||||||
|
"zz" => {
|
||||||
|
o.put("s", "size", &anim::num);
|
||||||
|
o.put("r", "ridges", &anim::num);
|
||||||
|
o.raw("pt", json!({"a": 0, "k": o.e("points") + 1}));
|
||||||
|
}
|
||||||
|
"mm" => o.raw("mm", json!(o.e("mode") + 1)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
out.push(o.done());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- import
|
||||||
|
|
||||||
|
fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option<Value>) {
|
||||||
|
if let (Some(j), Some(p)) = (j, g.get_mut(m)) {
|
||||||
|
import_prop(im, j, p, conv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_enum(g: &mut PropGroup, m: &str, v: u32) {
|
||||||
|
if let Some(p) = g.get_mut(m) {
|
||||||
|
p.value = Value::Enum(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int(j: &Json, k: &str) -> Option<i64> {
|
||||||
|
let v = j.get(k)?;
|
||||||
|
v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64)).or_else(|| v.get("k").and_then(anim::nums).and_then(|n| n.first().map(|f| f.round() as i64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_blend(j: &Json, g: &mut PropGroup) {
|
||||||
|
if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
|
||||||
|
set_enum(g, "blend", blend_index(crate::blend_from_lottie(bm)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
|
||||||
|
set(im, g, "width", j.get("w"), &anim::to_scalar);
|
||||||
|
if let Some(lc) = int(j, "lc") {
|
||||||
|
set_enum(g, "cap", (lc - 1).clamp(0, 2) as u32);
|
||||||
|
}
|
||||||
|
if let Some(lj) = int(j, "lj") {
|
||||||
|
set_enum(g, "join", (lj - 1).clamp(0, 2) as u32);
|
||||||
|
}
|
||||||
|
if let Some(p) = g.get_mut("miter") {
|
||||||
|
if let Some(ml) = j.get("ml").and_then(Json::as_f64) {
|
||||||
|
p.value = Value::Scalar(ml);
|
||||||
|
} else if let Some(ml2) = j.get("ml2") {
|
||||||
|
import_prop(im, ml2, p, &anim::to_scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(list) = j.get("d").and_then(Json::as_array)
|
||||||
|
&& let Some(d) = g.sub_mut("dashes")
|
||||||
|
{
|
||||||
|
let mut seen_dash = false;
|
||||||
|
for e in list {
|
||||||
|
let m = match e.get("n").and_then(Json::as_str) {
|
||||||
|
Some("d") if !seen_dash => {
|
||||||
|
seen_dash = true;
|
||||||
|
"dash"
|
||||||
|
}
|
||||||
|
Some("g") => "gap",
|
||||||
|
Some("o") => "offset",
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
set(im, d, m, e.get("v"), &anim::to_scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) {
|
||||||
|
set(im, tr, "anchor", j.get("a"), &anim::to_vec2);
|
||||||
|
set(im, tr, "position", j.get("p"), &anim::to_vec2);
|
||||||
|
set(im, tr, "scale", j.get("s"), &anim::to_vec2);
|
||||||
|
set(im, tr, "rotation", j.get("r"), &anim::to_scalar);
|
||||||
|
set(im, tr, "opacity", j.get("o"), &anim::to_scalar);
|
||||||
|
set(im, tr, "skew", j.get("sk"), &anim::to_scalar);
|
||||||
|
set(im, tr, "skewAxis", j.get("sa"), &anim::to_scalar);
|
||||||
|
set(im, tr, "startOpacity", j.get("so"), &anim::to_scalar);
|
||||||
|
set(im, tr, "endOpacity", j.get("eo"), &anim::to_scalar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import Lottie shape items into shape contents groups.
|
||||||
|
pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<PropGroup> {
|
||||||
|
let mut out = vec![];
|
||||||
|
for j in items {
|
||||||
|
let ty = j.get("ty").and_then(Json::as_str).unwrap_or("");
|
||||||
|
let dir = |g: &mut PropGroup| {
|
||||||
|
if int(j, "d") == Some(3) {
|
||||||
|
set_enum(g, "direction", 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut g = match ty {
|
||||||
|
"gr" => {
|
||||||
|
let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||||
|
let inner: Vec<Json> = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect();
|
||||||
|
let children = import_items(im, ids, &inner);
|
||||||
|
let mut g = build::shape_group(ids, "Group 1", children);
|
||||||
|
if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr"))
|
||||||
|
&& let Some(t) = g.sub_mut("transform")
|
||||||
|
{
|
||||||
|
import_shape_transform(im, tr, t);
|
||||||
|
}
|
||||||
|
import_blend(j, &mut g);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"rc" => {
|
||||||
|
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], 0.0);
|
||||||
|
dir(&mut g);
|
||||||
|
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"el" => {
|
||||||
|
let mut g = build::shape_ellipse(ids, [100.0, 100.0], [0.0, 0.0]);
|
||||||
|
dir(&mut g);
|
||||||
|
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"sr" => {
|
||||||
|
let star = int(j, "sy").unwrap_or(1) != 2;
|
||||||
|
let mut g = build::shape_star(ids, star, 5.0, [0.0, 0.0], 100.0, 50.0);
|
||||||
|
dir(&mut g);
|
||||||
|
set(im, &mut g, "points", j.get("pt"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "rotation", j.get("r"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "innerRadius", j.get("ir"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "outerRadius", j.get("or"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "innerRoundness", j.get("is"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "outerRoundness", j.get("os"), &anim::to_scalar);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"sh" => {
|
||||||
|
let mut g = build::shape_path(ids, ShapePath::default());
|
||||||
|
dir(&mut g);
|
||||||
|
set(im, &mut g, "path", j.get("ks"), &to_path);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"fl" => {
|
||||||
|
let mut g = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
|
||||||
|
set(im, &mut g, "color", j.get("c"), &anim::to_color);
|
||||||
|
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
|
||||||
|
if int(j, "r") == Some(2) {
|
||||||
|
set_enum(&mut g, "rule", 1);
|
||||||
|
}
|
||||||
|
import_blend(j, &mut g);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"st" => {
|
||||||
|
let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
|
||||||
|
set(im, &mut g, "color", j.get("c"), &anim::to_color);
|
||||||
|
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
|
||||||
|
import_stroke(im, j, &mut g);
|
||||||
|
import_blend(j, &mut g);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"gf" | "gs" => {
|
||||||
|
let radial = int(j, "t") == Some(2);
|
||||||
|
let mut g = if ty == "gf" {
|
||||||
|
build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default())
|
||||||
|
} else {
|
||||||
|
build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0)
|
||||||
|
};
|
||||||
|
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "start", j.get("s"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "end", j.get("e"), &anim::to_vec2);
|
||||||
|
set(im, &mut g, "highlightLength", j.get("h"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "highlightAngle", j.get("a"), &anim::to_scalar);
|
||||||
|
if let Some(gj) = j.get("g") {
|
||||||
|
let n = gj.get("p").and_then(Json::as_u64).unwrap_or(2) as usize;
|
||||||
|
if let Some(k) = gj.get("k") {
|
||||||
|
set(im, &mut g, "colors", Some(k), &move |v| to_gradient(v, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ty == "gf" {
|
||||||
|
if int(j, "r") == Some(2) {
|
||||||
|
set_enum(&mut g, "rule", 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
import_stroke(im, j, &mut g);
|
||||||
|
}
|
||||||
|
import_blend(j, &mut g);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"tm" => {
|
||||||
|
let mut g = build::shape_trim(ids, 0.0, 100.0, 0.0);
|
||||||
|
set(im, &mut g, "start", j.get("s"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "end", j.get("e"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
|
||||||
|
if int(j, "m") == Some(2) {
|
||||||
|
set_enum(&mut g, "mode", 1);
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"rp" => {
|
||||||
|
let mut g = build::shape_repeater(ids, 3.0, [100.0, 0.0]);
|
||||||
|
set(im, &mut g, "copies", j.get("c"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
|
||||||
|
set_enum(&mut g, "composite", if int(j, "m") == Some(1) { 1 } else { 0 });
|
||||||
|
if let (Some(tr), Some(t)) = (j.get("tr"), g.sub_mut("transform")) {
|
||||||
|
import_shape_transform(im, tr, t);
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"rd" | "op" | "pb" | "tw" | "zz" | "mm" => {
|
||||||
|
let kind = match ty {
|
||||||
|
"rd" => "round",
|
||||||
|
"op" => "offset",
|
||||||
|
"pb" => "pucker",
|
||||||
|
"tw" => "twist",
|
||||||
|
"zz" => "zigzag",
|
||||||
|
_ => "merge",
|
||||||
|
};
|
||||||
|
let Some(mut g) = build::shape_simple_op(ids, kind) else { continue };
|
||||||
|
match ty {
|
||||||
|
"rd" => set(im, &mut g, "radius", j.get("r"), &anim::to_scalar),
|
||||||
|
"op" => {
|
||||||
|
set(im, &mut g, "amount", j.get("a"), &anim::to_scalar);
|
||||||
|
if let Some(lj) = int(j, "lj") {
|
||||||
|
set_enum(&mut g, "join", (lj - 1).clamp(0, 2) as u32);
|
||||||
|
}
|
||||||
|
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
|
||||||
|
p.value = Value::Scalar(ml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar),
|
||||||
|
"tw" => {
|
||||||
|
set(im, &mut g, "angle", j.get("a"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "center", j.get("c"), &anim::to_vec2);
|
||||||
|
}
|
||||||
|
"zz" => {
|
||||||
|
set(im, &mut g, "size", j.get("s"), &anim::to_scalar);
|
||||||
|
set(im, &mut g, "ridges", j.get("r"), &anim::to_scalar);
|
||||||
|
if int(j, "pt") == Some(2) {
|
||||||
|
set_enum(&mut g, "points", 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(m) = int(j, "mm") {
|
||||||
|
set_enum(&mut g, "mode", (m - 1).clamp(0, 4) as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
|
"tr" => continue,
|
||||||
|
other => {
|
||||||
|
im.warn(format!("shape item `{other}` is not supported (skipped)"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(nm) = j.get("nm").and_then(Json::as_str) {
|
||||||
|
g.name = nm.to_string();
|
||||||
|
}
|
||||||
|
if j.get("hd").and_then(Json::as_bool) == Some(true) {
|
||||||
|
g.enabled = false;
|
||||||
|
}
|
||||||
|
out.push(g);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,700 @@
|
||||||
|
//! Round-trip tests: build comps feature by feature, export → import, compare property values
|
||||||
|
//! and rendered frames; plus easing math and document-level checks.
|
||||||
|
|
||||||
|
use effectcraft_color::{BlendMode, Label};
|
||||||
|
use effectcraft_keyframe::{Ease, Gradient, Interp, Keyframe, ShapePath, TextDoc, Value};
|
||||||
|
use effectcraft_project::build::{self, Ids};
|
||||||
|
use effectcraft_project::{
|
||||||
|
AlphaMode, Comp, Expression, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, MatteKind, Node, Project,
|
||||||
|
PropGroup, Solid, TrackMatte,
|
||||||
|
};
|
||||||
|
use effectcraft_render::render_frame;
|
||||||
|
use effectcraft_time::{FrameRate, Tick};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
|
use crate::{ExportOptions, export_comp, import};
|
||||||
|
|
||||||
|
fn s(x: f64) -> Tick {
|
||||||
|
Tick::from_seconds_f64(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup() -> (Project, ItemId) {
|
||||||
|
let mut p = Project::default();
|
||||||
|
let comp = Comp::new(200, 120, FrameRate::FPS_30, s(2.0));
|
||||||
|
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||||
|
(p, cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comp(p: &Project, cid: ItemId) -> Comp {
|
||||||
|
p.comp(cid).unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(p: &mut Project, cid: ItemId, l: Layer) -> LayerId {
|
||||||
|
let id = l.id;
|
||||||
|
p.comp_mut(cid).unwrap().layers.push(l);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solid_layer(p: &mut Project, cid: ItemId, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||||||
|
let c = comp(p, cid);
|
||||||
|
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||||
|
build::layer(p, &c, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_layer(p: &mut Project, cid: ItemId, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> Layer {
|
||||||
|
let c = comp(p, cid);
|
||||||
|
let mut l = build::layer(p, &c, "Shape Layer 1", LayerSource::Shape, (c.width, c.height), None);
|
||||||
|
let mut next = p.next_id;
|
||||||
|
let groups = items(&mut Ids(&mut next));
|
||||||
|
p.next_id = next;
|
||||||
|
l.props.sub_mut("contents").unwrap().children.extend(groups.into_iter().map(Node::Group));
|
||||||
|
l
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_read(_: &str) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export comp `cid`, serialise, import into a fresh project.
|
||||||
|
fn roundtrip(p: &Project, cid: ItemId, opts: &ExportOptions) -> (Project, ItemId, Vec<String>, Json) {
|
||||||
|
let res = export_comp(p, cid, opts, &no_read).unwrap();
|
||||||
|
let text = res.to_string_compact();
|
||||||
|
let mut q = Project::default();
|
||||||
|
let imp = import(&mut q, text.as_bytes(), "Imported", "", &mut |_| None).unwrap();
|
||||||
|
(q, imp.comp, res.warnings, res.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: &Value, b: &Value, tol: f64) -> bool {
|
||||||
|
let near = |x: f64, y: f64| (x - y).abs() <= tol * (1.0 + x.abs().max(y.abs()) * 1e-3);
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Path(p), Value::Path(q)) => {
|
||||||
|
let pts = |u: &[[f64; 2]], v: &[[f64; 2]]| u.len() == v.len() && u.iter().zip(v).all(|(m, n)| near(m[0], n[0]) && near(m[1], n[1]));
|
||||||
|
p.closed == q.closed && pts(&p.vertices, &q.vertices) && pts(&p.in_tangents, &q.in_tangents) && pts(&p.out_tangents, &q.out_tangents)
|
||||||
|
}
|
||||||
|
(Value::Gradient(g), Value::Gradient(h)) => {
|
||||||
|
g.colors.len() == h.colors.len()
|
||||||
|
&& g.colors.iter().zip(&h.colors).all(|(x, y)| near(x.0, y.0) && (0..3).all(|i| near(x.1[i] as f64, y.1[i] as f64)))
|
||||||
|
&& g.opacities.iter().zip(&h.opacities).all(|(x, y)| near(x.0, y.0) && near(x.1 as f64, y.1 as f64))
|
||||||
|
}
|
||||||
|
(Value::Text(x), Value::Text(y)) => x == y,
|
||||||
|
_ => {
|
||||||
|
let (ca, cb) = (a.components(), b.components());
|
||||||
|
ca.len() == cb.len() && ca.iter().zip(&cb).all(|(x, y)| near(*x, *y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare every property of `a` with the same-path property of `b` at the sample times.
|
||||||
|
/// Returns the number of properties compared.
|
||||||
|
fn compare_tree(a: &PropGroup, b: &PropGroup, times: &[Tick], path: &str) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
for (i, c) in a.children.iter().enumerate() {
|
||||||
|
let occ = a.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count();
|
||||||
|
let Some(other) = b.children.iter().filter(|o| o.match_id() == c.match_id()).nth(occ) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let here = format!("{path}/{}", c.match_id());
|
||||||
|
match (c, other) {
|
||||||
|
(Node::Prop(x), Node::Prop(y)) => {
|
||||||
|
for t in times {
|
||||||
|
let (va, vb) = (x.value_at(*t), y.value_at(*t));
|
||||||
|
assert!(close(&va, &vb, 1e-3), "{here} at {:.3}s: {va:?} != {vb:?}", t.seconds());
|
||||||
|
}
|
||||||
|
assert_eq!(x.expr.as_ref().map(|e| &e.text), y.expr.as_ref().map(|e| &e.text), "{here}: expression");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
(Node::Group(x), Node::Group(y)) => {
|
||||||
|
if let (GroupKind::Mask { mode: m1, inverted: i1, .. }, GroupKind::Mask { mode: m2, inverted: i2, .. }) = (&x.kind, &y.kind) {
|
||||||
|
assert_eq!((m1, i1), (m2, i2), "{here}: mask mode");
|
||||||
|
}
|
||||||
|
assert_eq!(x.enabled, y.enabled, "{here}: enabled");
|
||||||
|
n += compare_tree(x, y, times, &here);
|
||||||
|
}
|
||||||
|
_ => panic!("{here}: node kind differs"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_times(c: &Comp) -> Vec<Tick> {
|
||||||
|
(0..=12).map(|i| Tick(c.duration.0 * i / 12)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare layers (timing, switches, mattes, parents, properties).
|
||||||
|
fn compare_comps(p: &Project, a: ItemId, q: &Project, b: ItemId) -> usize {
|
||||||
|
let (ca, cb) = (p.comp(a).unwrap(), q.comp(b).unwrap());
|
||||||
|
assert_eq!((ca.width, ca.height, ca.frame_rate, ca.duration), (cb.width, cb.height, cb.frame_rate, cb.duration));
|
||||||
|
assert_eq!(ca.layers.len(), cb.layers.len(), "layer count");
|
||||||
|
let times = sample_times(ca);
|
||||||
|
let mut n = 0;
|
||||||
|
for (la, lb) in ca.layers.iter().zip(&cb.layers) {
|
||||||
|
assert_eq!(la.name, lb.name);
|
||||||
|
assert_eq!(la.source.type_name(), lb.source.type_name(), "{}", la.name);
|
||||||
|
assert_eq!((la.in_point, la.out_point, la.start_time), (lb.in_point, lb.out_point, lb.start_time), "{}: timing", la.name);
|
||||||
|
assert!((la.stretch - lb.stretch).abs() < 1e-9, "{}: stretch", la.name);
|
||||||
|
assert_eq!(la.blend_mode, lb.blend_mode, "{}: blend", la.name);
|
||||||
|
assert_eq!(la.parent.and_then(|x| ca.index_of(x)), lb.parent.and_then(|x| cb.index_of(x)), "{}: parent", la.name);
|
||||||
|
assert_eq!(la.track_matte.map(|m| (ca.index_of(m.layer), m.kind)), lb.track_matte.map(|m| (cb.index_of(m.layer), m.kind)), "{}: matte", la.name);
|
||||||
|
n += compare_tree(&la.props, &lb.props, ×, &la.name);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendered frames must agree within a small tolerance.
|
||||||
|
fn compare_renders(p: &Project, a: ItemId, q: &Project, b: ItemId, tol: f32) {
|
||||||
|
let c = p.comp(a).unwrap();
|
||||||
|
assert!(sample_times(c).into_iter().any(|t| not_blank(p, a, t)), "nothing rendered");
|
||||||
|
for t in sample_times(c).into_iter().step_by(3) {
|
||||||
|
let (x, y) = (render_frame(p, a, t, 0.5), render_frame(q, b, t, 0.5));
|
||||||
|
assert_eq!((x.width, x.height), (y.width, y.height));
|
||||||
|
let mut worst = 0.0f32;
|
||||||
|
let mut bad = 0usize;
|
||||||
|
for (u, v) in x.data.iter().zip(&y.data) {
|
||||||
|
let d = (0..4).map(|i| (u[i] - v[i]).abs()).fold(0.0, f32::max);
|
||||||
|
worst = worst.max(d);
|
||||||
|
if d > tol {
|
||||||
|
bad += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(bad * 200 <= x.data.len(), "at {:.2}s: {bad} pixels differ by more than {tol} (worst {worst})", t.seconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_blank(p: &Project, cid: ItemId, t: Tick) -> bool {
|
||||||
|
render_frame(p, cid, t, 0.5).data.iter().any(|px| px[3] > 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- easing math
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easing_converts_exactly_both_ways() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let mut l = solid_layer(&mut p, cid, [1.0, 0.5, 0.0], 40, 40);
|
||||||
|
let tr = l.props.sub_mut("transform").unwrap();
|
||||||
|
// Custom speed / influence (non-spatial), easy ease, hold, auto-bezier and linear sides.
|
||||||
|
let rot = tr.get_mut("rotation").unwrap();
|
||||||
|
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
|
||||||
|
a.out_interp = Interp::Bezier;
|
||||||
|
a.out_ease = vec![Ease { speed: 300.0, influence: 0.6 }];
|
||||||
|
let mut b = Keyframe::new(s(0.8), Value::Scalar(180.0));
|
||||||
|
b.in_interp = Interp::Bezier;
|
||||||
|
b.in_ease = vec![Ease { speed: -50.0, influence: 0.2 }];
|
||||||
|
b.out_interp = Interp::Hold;
|
||||||
|
let c = Keyframe::new(s(1.2), Value::Scalar(90.0)).eased();
|
||||||
|
let mut d = Keyframe::new(s(1.6), Value::Scalar(45.0));
|
||||||
|
d.auto_bezier = true;
|
||||||
|
d.in_interp = Interp::Bezier;
|
||||||
|
d.out_interp = Interp::Bezier;
|
||||||
|
let e = Keyframe::new(s(1.9), Value::Scalar(0.0));
|
||||||
|
rot.keys = vec![a, b, c, d, e];
|
||||||
|
let sc = tr.get_mut("scale").unwrap();
|
||||||
|
sc.keys = vec![Keyframe::new(s(0.0), Value::Vec3([100.0, 50.0, 100.0])).eased(), Keyframe::new(s(1.0), Value::Vec3([20.0, 150.0, 100.0])).eased()];
|
||||||
|
sc.keys[0].out_ease = vec![Ease { speed: 10.0, influence: 0.9 }, Ease { speed: 0.0, influence: 0.1 }, Ease::EASY];
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
// The Lottie tangents of the first rotation segment: o = (0.6, 300·0.6·0.8/180).
|
||||||
|
let k0 = &json["layers"][0]["ks"]["r"]["k"][0];
|
||||||
|
assert!((k0["o"]["x"][0].as_f64().unwrap() - 0.6).abs() < 1e-9);
|
||||||
|
assert!((k0["o"]["y"][0].as_f64().unwrap() - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5);
|
||||||
|
assert_eq!(json["layers"][0]["ks"]["r"]["k"][1]["h"], json!(1));
|
||||||
|
let rq = &q.comp(nc).unwrap().layers[0];
|
||||||
|
let rk = &rq.props.prop("transform/rotation").unwrap().keys;
|
||||||
|
assert!((rk[0].out_ease[0].speed - 300.0).abs() < 1e-3 && (rk[0].out_ease[0].influence - 0.6).abs() < 1e-6, "{:?}", rk[0].out_ease);
|
||||||
|
assert!((rk[1].in_ease[0].speed + 50.0).abs() < 1e-3);
|
||||||
|
assert_eq!(rk[1].out_interp, Interp::Hold);
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spatial_tangents_and_separated_dimensions() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
|
||||||
|
let pos = l.props.prop_mut("transform/position").unwrap();
|
||||||
|
let mut k1 = Keyframe::new(s(0.0), Value::Vec3([20.0, 20.0, 0.0])).eased();
|
||||||
|
k1.spatial_auto = false;
|
||||||
|
k1.spatial_out = [80.0, 0.0, 0.0];
|
||||||
|
let k2 = Keyframe::new(s(1.0), Value::Vec3([180.0, 100.0, 0.0])).eased();
|
||||||
|
let k3 = Keyframe::new(s(1.5), Value::Vec3([100.0, 60.0, 0.0]));
|
||||||
|
pos.keys = vec![k1, k2, k3];
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
// A second layer with separated X/Y position.
|
||||||
|
let mut l2 = solid_layer(&mut p, cid, [0.0, 0.0, 1.0], 20, 20);
|
||||||
|
let mut next = p.next_id;
|
||||||
|
let tr = l2.props.sub_mut("transform").unwrap();
|
||||||
|
for (m, name, keys) in [
|
||||||
|
("positionY", "Y Position", vec![Keyframe::new(s(0.0), Value::Scalar(10.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(110.0))]),
|
||||||
|
("positionX", "X Position", vec![Keyframe::new(s(0.5), Value::Scalar(30.0)), Keyframe::new(s(1.5), Value::Scalar(170.0)).eased()]),
|
||||||
|
] {
|
||||||
|
let mut pr = effectcraft_project::Property::new(next, m, name, Value::Scalar(0.0));
|
||||||
|
next += 1;
|
||||||
|
pr.keys = keys;
|
||||||
|
tr.children.insert(2, Node::Prop(pr));
|
||||||
|
}
|
||||||
|
let mut pz = effectcraft_project::Property::new(next, "positionZ", "Z Position", Value::Scalar(0.0));
|
||||||
|
pz.three_d_only = true;
|
||||||
|
next += 1;
|
||||||
|
tr.children.insert(4, Node::Prop(pz));
|
||||||
|
p.next_id = next;
|
||||||
|
push(&mut p, cid, l2);
|
||||||
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert_eq!(json["layers"][1]["ks"]["p"]["s"], json!(true));
|
||||||
|
let to = &json["layers"][0]["ks"]["p"]["k"][0]["to"];
|
||||||
|
assert_eq!(to[0].as_f64(), Some(80.0));
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- shapes
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_contents_roundtrip() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let l = shape_layer(&mut p, cid, |ids| {
|
||||||
|
let mut rect = build::shape_rect(ids, [60.0, 40.0], [0.0, 0.0], 6.0);
|
||||||
|
rect.get_mut("size").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 40.0])).eased(), Keyframe::new(s(1.5), Value::Vec2([90.0, 20.0]))];
|
||||||
|
let ell = build::shape_ellipse(ids, [30.0, 30.0], [20.0, 10.0]);
|
||||||
|
let mut stroke = build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 3.0);
|
||||||
|
{
|
||||||
|
let d = stroke.sub_mut("dashes").unwrap();
|
||||||
|
d.get_mut("dash").unwrap().value = Value::Scalar(6.0);
|
||||||
|
d.get_mut("gap").unwrap().value = Value::Scalar(3.0);
|
||||||
|
}
|
||||||
|
stroke.get_mut("cap").unwrap().value = Value::Enum(1);
|
||||||
|
stroke.get_mut("join").unwrap().value = Value::Enum(2);
|
||||||
|
let mut fill = build::shape_fill(ids, [1.0, 0.2, 0.2, 1.0]);
|
||||||
|
fill.get_mut("color").unwrap().keys =
|
||||||
|
vec![Keyframe::new(s(0.0), Value::Color([1.0, 0.2, 0.2, 1.0])).hold(), Keyframe::new(s(1.0), Value::Color([0.2, 1.0, 0.2, 1.0])).hold()];
|
||||||
|
fill.get_mut("rule").unwrap().value = Value::Enum(1);
|
||||||
|
let mut g = build::shape_group(ids, "Group A", vec![rect, ell, stroke, fill]);
|
||||||
|
let tr = g.sub_mut("transform").unwrap();
|
||||||
|
tr.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 60.0])), Keyframe::new(s(2.0), Value::Vec2([90.0, 50.0]))];
|
||||||
|
tr.get_mut("rotation").unwrap().value = Value::Scalar(15.0);
|
||||||
|
tr.get_mut("skew").unwrap().value = Value::Scalar(10.0);
|
||||||
|
g.get_mut("blend").unwrap().value = Value::Enum(BlendMode::ALL.iter().position(|b| *b == BlendMode::Screen).unwrap() as u32);
|
||||||
|
let star = build::shape_star(ids, true, 5.0, [150.0, 40.0], 30.0, 12.0);
|
||||||
|
let poly = build::shape_star(ids, false, 6.0, [150.0, 95.0], 20.0, 0.0);
|
||||||
|
let path = build::shape_path(
|
||||||
|
ids,
|
||||||
|
ShapePath {
|
||||||
|
vertices: vec![[10.0, 110.0], [40.0, 80.0], [70.0, 110.0]],
|
||||||
|
in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]],
|
||||||
|
out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]],
|
||||||
|
closed: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let grad = Gradient {
|
||||||
|
colors: vec![(0.0, [1.0, 1.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 1.0, 1.0])],
|
||||||
|
opacities: vec![(0.0, 1.0), (1.0, 0.5)],
|
||||||
|
};
|
||||||
|
let gfill = build::shape_gradient_fill(ids, true, [150.0, 40.0], [180.0, 40.0], grad.clone());
|
||||||
|
let gstroke = build::shape_gradient_stroke(ids, false, [130.0, 95.0], [170.0, 95.0], grad, 4.0);
|
||||||
|
let mut g2 = build::shape_group(ids, "Group B", vec![star, gfill, poly, gstroke]);
|
||||||
|
g2.enabled = true;
|
||||||
|
let mut fill2 = build::shape_fill(ids, [0.9, 0.9, 0.1, 1.0]);
|
||||||
|
fill2.get_mut("opacity").unwrap().value = Value::Scalar(70.0);
|
||||||
|
let g3 = build::shape_group(ids, "Group C", vec![path, fill2]);
|
||||||
|
vec![g, g2, g3]
|
||||||
|
});
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert!(warnings.is_empty(), "{warnings:?}");
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 60);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_operators_roundtrip() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let l = shape_layer(&mut p, cid, |ids| {
|
||||||
|
let mut out = vec![];
|
||||||
|
for (k, (x, y)) in ["round", "offset", "pucker", "twist", "zigzag", "merge"].iter().zip([
|
||||||
|
(30.0, 30.0),
|
||||||
|
(90.0, 30.0),
|
||||||
|
(150.0, 30.0),
|
||||||
|
(30.0, 90.0),
|
||||||
|
(90.0, 90.0),
|
||||||
|
(150.0, 90.0),
|
||||||
|
]) {
|
||||||
|
let rect = build::shape_rect(ids, [36.0, 30.0], [x, y], 0.0);
|
||||||
|
let ell = build::shape_ellipse(ids, [20.0, 20.0], [x + 10.0, y]);
|
||||||
|
let mut op = build::shape_simple_op(ids, k).unwrap();
|
||||||
|
match *k {
|
||||||
|
"pucker" => op.get_mut("amount").unwrap().value = Value::Scalar(-30.0),
|
||||||
|
"twist" => op.get_mut("angle").unwrap().value = Value::Scalar(40.0),
|
||||||
|
"zigzag" => op.get_mut("points").unwrap().value = Value::Enum(1),
|
||||||
|
"merge" => op.get_mut("mode").unwrap().value = Value::Enum(2),
|
||||||
|
"offset" => op.get_mut("amount").unwrap().value = Value::Scalar(-4.0),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let fill = build::shape_fill(ids, [0.2, 0.6, 1.0, 1.0]);
|
||||||
|
out.push(build::shape_group(ids, k, vec![rect, ell, op, fill]));
|
||||||
|
}
|
||||||
|
// Trim paths (animated) and a repeater.
|
||||||
|
let ell = build::shape_ellipse(ids, [50.0, 50.0], [100.0, 60.0]);
|
||||||
|
let mut trim = build::shape_trim(ids, 0.0, 100.0, 0.0);
|
||||||
|
trim.get_mut("end").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.5), Value::Scalar(100.0)).eased()];
|
||||||
|
trim.get_mut("mode").unwrap().value = Value::Enum(1);
|
||||||
|
let st = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 4.0);
|
||||||
|
out.push(build::shape_group(ids, "Trim", vec![ell, trim, st]));
|
||||||
|
let r = build::shape_rect(ids, [8.0, 8.0], [10.0, 10.0], 0.0);
|
||||||
|
let f = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
let mut rep = build::shape_repeater(ids, 4.0, [12.0, 0.0]);
|
||||||
|
rep.get_mut("composite").unwrap().value = Value::Enum(1);
|
||||||
|
let rt = rep.sub_mut("transform").unwrap();
|
||||||
|
rt.get_mut("rotation").unwrap().value = Value::Scalar(10.0);
|
||||||
|
rt.get_mut("endOpacity").unwrap().value = Value::Scalar(30.0);
|
||||||
|
out.push(build::shape_group(ids, "Repeat", vec![r, f, rep]));
|
||||||
|
out
|
||||||
|
});
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert!(warnings.is_empty(), "{warnings:?}");
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 50);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- layers
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layers_parenting_timing_mattes_masks_and_blend_modes() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let c = comp(&p, cid);
|
||||||
|
// Null parent with rotation; child solid.
|
||||||
|
let mut null = build::layer(&mut p, &c, "Null 1", LayerSource::Null, (100, 100), None);
|
||||||
|
null.props.prop_mut("transform/rotation").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(90.0))];
|
||||||
|
let null_id = null.id;
|
||||||
|
let mut child = solid_layer(&mut p, cid, [1.0, 1.0, 0.0], 40, 20);
|
||||||
|
child.parent = Some(null_id);
|
||||||
|
child.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 0.0, 0.0]);
|
||||||
|
child.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([150.0, 80.0, 100.0]);
|
||||||
|
child.in_point = s(0.2);
|
||||||
|
child.out_point = s(1.8);
|
||||||
|
child.start_time = s(0.1);
|
||||||
|
child.stretch = 200.0;
|
||||||
|
child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(20.0)), Keyframe::new(s(0.8), Value::Scalar(100.0))];
|
||||||
|
// Matte (ellipse shape) over a solid with masks and a blend mode.
|
||||||
|
let matte = shape_layer(&mut p, cid, |ids| {
|
||||||
|
let e = build::shape_ellipse(ids, [120.0, 80.0], [100.0, 60.0]);
|
||||||
|
let f = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
|
||||||
|
vec![build::shape_group(ids, "Ellipse", vec![e, f])]
|
||||||
|
});
|
||||||
|
let mut matte = matte;
|
||||||
|
matte.name = "Matte".into();
|
||||||
|
matte.switches.video = false;
|
||||||
|
let matte_id = matte.id;
|
||||||
|
let mut masked = solid_layer(&mut p, cid, [0.2, 0.4, 1.0], 200, 120);
|
||||||
|
masked.name = "Masked".into();
|
||||||
|
masked.blend_mode = BlendMode::Multiply;
|
||||||
|
masked.track_matte = Some(TrackMatte { layer: matte_id, kind: MatteKind::Alpha });
|
||||||
|
let mut next = p.next_id;
|
||||||
|
{
|
||||||
|
let mut ids = Ids(&mut next);
|
||||||
|
let mut m1 = build::mask(&mut ids, "Mask 1", ShapePath::rect([100.0, 60.0], 150.0, 90.0), MaskMode::Add, [255, 0, 0]);
|
||||||
|
m1.get_mut("expansion").unwrap().value = Value::Scalar(-5.0);
|
||||||
|
m1.get_mut("path").unwrap().keys = vec![
|
||||||
|
Keyframe::new(s(0.0), Value::Path(ShapePath::rect([100.0, 60.0], 150.0, 90.0))),
|
||||||
|
Keyframe::new(s(2.0), Value::Path(ShapePath::rect([90.0, 60.0], 120.0, 80.0))),
|
||||||
|
];
|
||||||
|
let mut m2 = build::mask(&mut ids, "Mask 2", ShapePath::ellipse([100.0, 60.0], 40.0, 40.0), MaskMode::Subtract, [0, 255, 0]);
|
||||||
|
m2.get_mut("opacity").unwrap().value = Value::Scalar(60.0);
|
||||||
|
let mut m3 = build::mask(&mut ids, "Mask 3", ShapePath::rect([30.0, 30.0], 20.0, 20.0), MaskMode::Add, [0, 0, 255]);
|
||||||
|
if let GroupKind::Mask { inverted, .. } = &mut m3.kind {
|
||||||
|
*inverted = true;
|
||||||
|
}
|
||||||
|
m3.get_mut("feather").unwrap().value = Value::Vec2([4.0, 4.0]);
|
||||||
|
let masks = masked.props.sub_mut("masks").unwrap();
|
||||||
|
masks.children.extend([m1, m2, m3].map(Node::Group));
|
||||||
|
}
|
||||||
|
p.next_id = next;
|
||||||
|
// A luma-inverted matte pair below.
|
||||||
|
let mut luma = solid_layer(&mut p, cid, [0.5, 0.5, 0.5], 200, 120);
|
||||||
|
luma.name = "Luma".into();
|
||||||
|
luma.switches.video = false;
|
||||||
|
let luma_id = luma.id;
|
||||||
|
let mut bg = solid_layer(&mut p, cid, [0.9, 0.1, 0.1], 200, 120);
|
||||||
|
bg.name = "Bg".into();
|
||||||
|
bg.track_matte = Some(TrackMatte { layer: luma_id, kind: MatteKind::LumaInverted });
|
||||||
|
for l in [null, child, matte, masked, luma, bg] {
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
}
|
||||||
|
p.comp_mut(cid).unwrap().markers.push(Marker { time: s(1.0), duration: s(0.5), comment: "beat".into(), ..Default::default() });
|
||||||
|
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert!(warnings.iter().any(|w| w.contains("feather")), "{warnings:?}");
|
||||||
|
assert_eq!(json["layers"][2]["td"], json!(1));
|
||||||
|
assert_eq!(json["layers"][3]["tt"], json!(1));
|
||||||
|
assert_eq!(json["layers"][3]["masksProperties"][1]["mode"], json!("s"));
|
||||||
|
assert_eq!(json["layers"][5]["tt"], json!(4));
|
||||||
|
assert_eq!(json["markers"][0]["cm"], json!("beat"));
|
||||||
|
assert_eq!(q.comp(nc).unwrap().markers[0].time, s(1.0));
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 20);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn precomp_with_time_remap() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let inner = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(Comp::new(100, 100, FrameRate::FPS_30, s(2.0)).into()));
|
||||||
|
let sl = shape_layer(&mut p, inner, |ids| {
|
||||||
|
let mut r = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
|
||||||
|
r.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([-20.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([60.0, 0.0]))];
|
||||||
|
let f = build::shape_fill(ids, [0.0, 1.0, 0.5, 1.0]);
|
||||||
|
vec![build::shape_group(ids, "Box", vec![r, f])]
|
||||||
|
});
|
||||||
|
push(&mut p, inner, sl);
|
||||||
|
let c = comp(&p, cid);
|
||||||
|
let mut pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, (100, 100), None);
|
||||||
|
let mut next = p.next_id;
|
||||||
|
let mut tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(0.0));
|
||||||
|
tr.keys = vec![Keyframe::new(s(0.0), Value::Scalar(1.5)), Keyframe::new(s(2.0), Value::Scalar(0.0))];
|
||||||
|
p.next_id = next;
|
||||||
|
pre.props.children.insert(0, Node::Prop(tr));
|
||||||
|
push(&mut p, cid, pre);
|
||||||
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert_eq!(json["assets"].as_array().unwrap().len(), 1);
|
||||||
|
assert!(json["layers"][0]["tm"].is_object());
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 5);
|
||||||
|
let qi = match &q.comp(nc).unwrap().layers[0].source {
|
||||||
|
LayerSource::Comp { item } => *item,
|
||||||
|
_ => panic!("not a precomp"),
|
||||||
|
};
|
||||||
|
assert!(compare_comps(&p, inner, &q, qi) > 3);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_layers_roundtrip_as_text_and_as_shapes() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let c = comp(&p, cid);
|
||||||
|
let mut l = build::layer(&mut p, &c, "Title", LayerSource::Text, (c.width, c.height), None);
|
||||||
|
let doc = TextDoc { text: "Hi\nthere".into(), size: 30.0, fill: [1.0, 0.8, 0.1, 1.0], tracking: 20.0, ..Default::default() };
|
||||||
|
let doc2 = TextDoc { text: "Bye".into(), ..doc.clone() };
|
||||||
|
let st = l.props.prop_mut("text/sourceText").unwrap();
|
||||||
|
st.value = Value::Text(Box::new(doc.clone()));
|
||||||
|
st.keys = vec![Keyframe::new(s(0.0), Value::Text(Box::new(doc))).hold(), Keyframe::new(s(1.5), Value::Text(Box::new(doc2))).hold()];
|
||||||
|
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 50.0, 0.0]);
|
||||||
|
let mut next = p.next_id;
|
||||||
|
{
|
||||||
|
let mut ids = Ids(&mut next);
|
||||||
|
let mut props = build::text_anim_props(&mut ids, "opacity", false);
|
||||||
|
props.extend(build::text_anim_props(&mut ids, "position", false));
|
||||||
|
let mut a = build::text_animator(&mut ids, "Animator 1", props);
|
||||||
|
let pr = a.sub_mut("properties").unwrap();
|
||||||
|
pr.get_mut("opacity").unwrap().value = Value::Scalar(0.0);
|
||||||
|
pr.get_mut("position").unwrap().value = Value::Vec3([0.0, 20.0, 0.0]);
|
||||||
|
let sel = a.sub_mut("selectors").unwrap().children[0].as_group_mut().unwrap();
|
||||||
|
sel.get_mut("start").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0))];
|
||||||
|
sel.sub_mut("advanced").unwrap().get_mut("shape").unwrap().value = Value::Enum(1);
|
||||||
|
l.props.group_mut("text/animators").unwrap().children.push(Node::Group(a));
|
||||||
|
}
|
||||||
|
p.next_id = next;
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||||
|
assert_eq!(json["layers"][0]["ty"], json!(5));
|
||||||
|
assert_eq!(json["layers"][0]["t"]["d"]["k"][0]["s"]["t"], json!("Hi\rthere"));
|
||||||
|
assert_eq!(json["fonts"]["list"][0]["fFamily"], json!("Inter"));
|
||||||
|
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
||||||
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||||
|
// As glyph shapes: the static look at time 0 survives (animators baked out).
|
||||||
|
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.clear();
|
||||||
|
let opts = ExportOptions { text_as_shapes: true, ..Default::default() };
|
||||||
|
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
|
||||||
|
assert_eq!(res.json["layers"][0]["ty"], json!(4));
|
||||||
|
let mut q2 = Project::default();
|
||||||
|
let imp = import(&mut q2, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
|
||||||
|
let (a, b) = (render_frame(&p, cid, s(0.5), 1.0), render_frame(&q2, imp.comp, s(0.5), 1.0));
|
||||||
|
let (ca, cb) = (a.data.iter().map(|px| px[3] as f64).sum::<f64>(), b.data.iter().map(|px| px[3] as f64).sum::<f64>());
|
||||||
|
assert!(ca > 50.0 && (ca - cb).abs() / ca < 0.05, "glyph coverage {ca} vs {cb}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effects_expressions_and_warnings() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let mut l = solid_layer(&mut p, cid, [1.0, 1.0, 1.0], 80, 60);
|
||||||
|
let mut next = p.next_id;
|
||||||
|
{
|
||||||
|
let mut ids = Ids(&mut next);
|
||||||
|
let fx = l.props.sub_mut("effects").unwrap();
|
||||||
|
for id in ["ec.blur.gaussian", "ec.color.tint", "ec.perspective.dropshadow", "ec.control.slider", "ec.distort.twirl"] {
|
||||||
|
if let Some(spec) = effectcraft_effects::find(id) {
|
||||||
|
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [80.0, 60.0]);
|
||||||
|
if let Some(b) = g.get_mut("blurriness") {
|
||||||
|
b.keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(8.0))];
|
||||||
|
}
|
||||||
|
if let Some(o) = g.get_mut("opacity") {
|
||||||
|
o.value = Value::Scalar(40.0);
|
||||||
|
}
|
||||||
|
if let Some(a) = g.get_mut("amount") {
|
||||||
|
a.value = Value::Scalar(60.0);
|
||||||
|
}
|
||||||
|
if let Some(sl) = g.get_mut("slider") {
|
||||||
|
sl.value = Value::Scalar(12.5);
|
||||||
|
}
|
||||||
|
fx.children.push(Node::Group(g));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.next_id = next;
|
||||||
|
l.props.prop_mut("transform/rotation").unwrap().expr = Some(Expression { text: "time * 90".into(), enabled: true });
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let c = comp(&p, cid);
|
||||||
|
let cam = build::layer(&mut p, &c, "Camera 1", LayerSource::Camera, (c.width, c.height), None);
|
||||||
|
push(&mut p, cid, cam);
|
||||||
|
// Without the expression flag: warned and dropped.
|
||||||
|
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
|
||||||
|
assert!(res.json["layers"][0]["ks"]["r"].get("x").is_none());
|
||||||
|
let w = res.warnings.join("\n");
|
||||||
|
assert!(w.contains("expression not exported"), "{w}");
|
||||||
|
assert!(w.contains("effect not supported by Lottie"), "{w}");
|
||||||
|
assert!(w.contains("cameras and lights"), "{w}");
|
||||||
|
// With it: `x` strings, and supported effects survive.
|
||||||
|
let opts = ExportOptions { include_expressions: true, ..Default::default() };
|
||||||
|
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
|
||||||
|
assert_eq!(res.json["layers"][0]["ks"]["r"]["x"], json!("time * 90"));
|
||||||
|
let ef = res.json["layers"][0]["ef"].as_array().unwrap();
|
||||||
|
assert_eq!(ef.iter().map(|e| e["ty"].as_u64().unwrap()).collect::<Vec<_>>(), vec![29, 20, 25, 5]);
|
||||||
|
assert!((ef[2]["ef"][1]["v"]["k"].as_f64().unwrap() - 40.0 * 2.55).abs() < 1e-9);
|
||||||
|
let mut q = Project::default();
|
||||||
|
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
|
||||||
|
let lq = &q.comp(imp.comp).unwrap().layers[0];
|
||||||
|
let fxq: Vec<&PropGroup> = lq.effects().unwrap().groups().collect();
|
||||||
|
assert_eq!(fxq.len(), 4);
|
||||||
|
assert!((fxq[2].get("opacity").unwrap().value.as_f64() - 40.0).abs() < 1e-9);
|
||||||
|
assert_eq!(fxq[3].get("slider").unwrap().value, Value::Scalar(12.5));
|
||||||
|
assert_eq!(lq.props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90");
|
||||||
|
let lp = &p.comp(cid).unwrap().layers[0];
|
||||||
|
let times = sample_times(p.comp(cid).unwrap());
|
||||||
|
compare_tree(lp.effects().unwrap().groups().next().unwrap(), fxq[0], ×, "blur");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn images_embed_as_base64_and_come_back() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let png: Vec<u8> = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5];
|
||||||
|
let fid = p.add_item(
|
||||||
|
"pic.png",
|
||||||
|
Label::Lavender,
|
||||||
|
None,
|
||||||
|
ItemKind::Footage(Footage {
|
||||||
|
path: "/virtual/pic.png".into(),
|
||||||
|
kind: FootageKind::Still,
|
||||||
|
width: 64,
|
||||||
|
height: 32,
|
||||||
|
pixel_aspect: 1.0,
|
||||||
|
frame_rate: FrameRate::FPS_30,
|
||||||
|
native_rate: None,
|
||||||
|
duration: Tick::ZERO,
|
||||||
|
has_video: true,
|
||||||
|
has_audio: false,
|
||||||
|
alpha: AlphaMode::Straight,
|
||||||
|
premul_color: [0.0; 3],
|
||||||
|
loop_count: 1,
|
||||||
|
codec: String::new(),
|
||||||
|
missing: false,
|
||||||
|
sequence: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let c = comp(&p, cid);
|
||||||
|
let l = build::layer(&mut p, &c, "pic.png", LayerSource::Footage { item: fid }, (64, 32), None);
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let bytes = png.clone();
|
||||||
|
let read = move |path: &str| (path == "/virtual/pic.png").then(|| bytes.clone());
|
||||||
|
let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap();
|
||||||
|
let asset = &res.json["assets"][0];
|
||||||
|
assert!(asset["p"].as_str().unwrap().starts_with("data:image/png;base64,"));
|
||||||
|
assert_eq!(res.json["layers"][0]["ty"], json!(2));
|
||||||
|
let mut q = Project::default();
|
||||||
|
let mut stored = vec![];
|
||||||
|
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |img| {
|
||||||
|
stored.push(img.bytes.clone());
|
||||||
|
Some(format!("/tmp/{}.{}", img.id, img.ext))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, vec![png]);
|
||||||
|
let l = &q.comp(imp.comp).unwrap().layers[0];
|
||||||
|
let LayerSource::Footage { item } = l.source else { panic!("not footage") };
|
||||||
|
let ItemKind::Footage(f) = &q.item(item).unwrap().kind else { panic!() };
|
||||||
|
assert_eq!((f.width, f.height), (64, 32));
|
||||||
|
assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotlottie_archive_roundtrip() {
|
||||||
|
let (mut p, cid) = setup();
|
||||||
|
let l = solid_layer(&mut p, cid, [1.0, 0.0, 0.0], 50, 50);
|
||||||
|
push(&mut p, cid, l);
|
||||||
|
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
|
||||||
|
let zip = crate::to_dotlottie(&res, "Main");
|
||||||
|
assert!(zip.starts_with(b"PK"));
|
||||||
|
let mut q = Project::default();
|
||||||
|
let imp = import(&mut q, &zip, "x", "", &mut |_| None).unwrap();
|
||||||
|
assert_eq!(q.comp(imp.comp).unwrap().layers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- hand-written fixtures
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> (Project, ItemId, Vec<String>) {
|
||||||
|
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let mut q = Project::default();
|
||||||
|
let imp = import(&mut q, &bytes, name, "", &mut |_| None).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
(q, imp.comp, imp.warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_bouncing_ball() {
|
||||||
|
let (q, c, w) = fixture("bouncing-ball.json");
|
||||||
|
assert!(w.is_empty(), "{w:?}");
|
||||||
|
let comp = q.comp(c).unwrap();
|
||||||
|
assert_eq!((comp.width, comp.height, comp.frame_rate), (200, 200, FrameRate::FPS_30));
|
||||||
|
let l = &comp.layers[0];
|
||||||
|
let pos = l.props.prop("transform/position").unwrap();
|
||||||
|
assert_eq!(pos.keys.len(), 3);
|
||||||
|
// Eased: slow at the top, the key values are hit exactly.
|
||||||
|
assert_eq!(pos.value_at(s(0.5)).as_vec3()[1], 160.0);
|
||||||
|
assert!(not_blank(&q, c, s(0.25)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_shapes_and_gradients() {
|
||||||
|
let (q, c, w) = fixture("shapes-gradient.json");
|
||||||
|
assert!(w.is_empty(), "{w:?}");
|
||||||
|
let l = &q.comp(c).unwrap().layers[0];
|
||||||
|
let contents = l.props.sub("contents").unwrap();
|
||||||
|
let g = contents.groups().next().unwrap();
|
||||||
|
let kinds: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||||||
|
assert_eq!(kinds, ["star", "rect", "gfill", "stroke", "trim"]);
|
||||||
|
assert!(not_blank(&q, c, s(0.5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_precomp_matte_text() {
|
||||||
|
let (q, c, w) = fixture("precomp-matte-text.json");
|
||||||
|
assert!(w.is_empty(), "{w:?}");
|
||||||
|
let comp = q.comp(c).unwrap();
|
||||||
|
assert_eq!(comp.layers.len(), 4);
|
||||||
|
let matte = &comp.layers[0];
|
||||||
|
assert!(!matte.switches.video);
|
||||||
|
assert_eq!(comp.layers[1].track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Alpha)));
|
||||||
|
assert!(matches!(comp.layers[1].source, LayerSource::Comp { .. }));
|
||||||
|
assert_eq!(comp.layers[2].parent, Some(comp.layers[3].id));
|
||||||
|
let Value::Text(doc) = &comp.layers[2].props.prop("text/sourceText").unwrap().value else { panic!() };
|
||||||
|
assert_eq!(doc.text, "Hello\nLottie");
|
||||||
|
assert_eq!(doc.justify, effectcraft_keyframe::Justify::Center);
|
||||||
|
assert!(not_blank(&q, c, s(0.5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_legacy_keyframes_and_masks() {
|
||||||
|
let (q, c, w) = fixture("legacy-masks.json");
|
||||||
|
assert!(w.iter().all(|w| w.contains("not supported")), "{w:?}");
|
||||||
|
let comp = q.comp(c).unwrap();
|
||||||
|
let l = &comp.layers[0];
|
||||||
|
// Legacy `e` end values fill the last key.
|
||||||
|
let o = l.props.prop("transform/opacity").unwrap();
|
||||||
|
assert_eq!(o.keys.len(), 2);
|
||||||
|
assert_eq!(o.keys[1].value, Value::Scalar(100.0));
|
||||||
|
let masks: Vec<_> = l.masks().unwrap().groups().collect();
|
||||||
|
assert_eq!(masks.len(), 2);
|
||||||
|
assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. }));
|
||||||
|
assert!(not_blank(&q, c, s(0.9)));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
//! Text layers ↔ Lottie text data (`t`: document keys `d`, animators `a`, more options `m`),
|
||||||
|
//! and text converted to glyph shapes.
|
||||||
|
|
||||||
|
use effectcraft_keyframe::{Justify, TextDoc, Value};
|
||||||
|
use effectcraft_project::build::{self, Ids};
|
||||||
|
use effectcraft_project::{Layer, Node, PropGroup};
|
||||||
|
use effectcraft_time::Tick;
|
||||||
|
use serde_json::{Map, Value as Json, json};
|
||||||
|
|
||||||
|
use crate::anim::{self, Ex, Im, export_prop, import_prop};
|
||||||
|
use crate::shapes;
|
||||||
|
|
||||||
|
const JUSTIFY: [Justify; 7] =
|
||||||
|
[Justify::Left, Justify::Right, Justify::Center, Justify::JustifyLastLeft, Justify::JustifyLastRight, Justify::JustifyLastCenter, Justify::JustifyAll];
|
||||||
|
|
||||||
|
/// Font key (`fName`) of a family/style.
|
||||||
|
pub(crate) fn font_name(family: &str, style: &str) -> String {
|
||||||
|
format!("{}-{}", family.replace(' ', ""), style.replace(' ', ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb(c: [f32; 4]) -> Json {
|
||||||
|
json!([c[0] as f64, c[1] as f64, c[2] as f64])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_json(d: &TextDoc) -> Json {
|
||||||
|
let mut o = json!({
|
||||||
|
"t": d.text.replace("\r\n", "\r").replace('\n', "\r"),
|
||||||
|
"f": font_name(&d.font, &d.style),
|
||||||
|
"s": d.size,
|
||||||
|
"j": JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0),
|
||||||
|
"tr": d.tracking,
|
||||||
|
"lh": d.leading.unwrap_or(d.size * 1.2),
|
||||||
|
"ls": d.baseline_shift,
|
||||||
|
"of": d.stroke_over_fill,
|
||||||
|
});
|
||||||
|
if d.apply_fill {
|
||||||
|
o["fc"] = rgb(d.fill);
|
||||||
|
}
|
||||||
|
if d.apply_stroke && d.stroke_width > 0.0 {
|
||||||
|
o["sc"] = rgb(d.stroke);
|
||||||
|
o["sw"] = json!(d.stroke_width);
|
||||||
|
}
|
||||||
|
if d.all_caps {
|
||||||
|
o["ca"] = json!(1);
|
||||||
|
} else if d.small_caps {
|
||||||
|
o["ca"] = json!(2);
|
||||||
|
}
|
||||||
|
if let Some(b) = d.box_size {
|
||||||
|
o["sz"] = json!(b);
|
||||||
|
o["ps"] = json!(d.box_pos);
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc {
|
||||||
|
let mut d = TextDoc::default();
|
||||||
|
let f = |k: &str| j.get(k).and_then(Json::as_f64);
|
||||||
|
if let Some(t) = j.get("t").and_then(Json::as_str) {
|
||||||
|
d.text = t.replace(['\r', '\u{3}'], "\n");
|
||||||
|
}
|
||||||
|
if let Some(name) = j.get("f").and_then(Json::as_str) {
|
||||||
|
match fonts.iter().find(|(n, _, _)| n == name) {
|
||||||
|
Some((_, fam, st)) => {
|
||||||
|
d.font = fam.clone();
|
||||||
|
d.style = st.clone();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let (fam, st) = name.split_once('-').unwrap_or((name, "Regular"));
|
||||||
|
d.font = fam.to_string();
|
||||||
|
d.style = st.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = f("s") {
|
||||||
|
d.size = s;
|
||||||
|
}
|
||||||
|
let col = |k: &str| j.get(k).and_then(anim::to_color).map(|v| v.as_color());
|
||||||
|
match col("fc") {
|
||||||
|
Some(c) => d.fill = c,
|
||||||
|
None => d.apply_fill = false,
|
||||||
|
}
|
||||||
|
if let Some(c) = col("sc") {
|
||||||
|
d.stroke = c;
|
||||||
|
d.apply_stroke = true;
|
||||||
|
d.stroke_width = f("sw").unwrap_or(1.0);
|
||||||
|
}
|
||||||
|
if let Some(of) = j.get("of").and_then(Json::as_bool) {
|
||||||
|
d.stroke_over_fill = of;
|
||||||
|
}
|
||||||
|
if let Some(i) = j.get("j").and_then(Json::as_u64) {
|
||||||
|
d.justify = JUSTIFY.get(i as usize).copied().unwrap_or_default();
|
||||||
|
}
|
||||||
|
d.tracking = f("tr").unwrap_or(0.0);
|
||||||
|
if let Some(lh) = f("lh")
|
||||||
|
&& (lh - d.size * 1.2).abs() > 1e-6
|
||||||
|
{
|
||||||
|
d.leading = Some(lh);
|
||||||
|
}
|
||||||
|
d.baseline_shift = f("ls").unwrap_or(0.0);
|
||||||
|
match j.get("ca").and_then(Json::as_u64) {
|
||||||
|
Some(1) => d.all_caps = true,
|
||||||
|
Some(2) => d.small_caps = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(sz) = j.get("sz").and_then(anim::nums).filter(|v| v.len() >= 2) {
|
||||||
|
d.box_size = Some([sz[0], sz[1]]);
|
||||||
|
if let Some(ps) = j.get("ps").and_then(anim::nums).filter(|v| v.len() >= 2) {
|
||||||
|
d.box_pos = [ps[0], ps[1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lottie text animator property key → (animator kind, match id).
|
||||||
|
const ANIM_PROPS: &[(&str, &str, &str)] = &[
|
||||||
|
("a", "anchor", "anchor"),
|
||||||
|
("p", "position", "position"),
|
||||||
|
("s", "scale", "scale"),
|
||||||
|
("sk", "skew", "skew"),
|
||||||
|
("sa", "skew", "skewAxis"),
|
||||||
|
("r", "rotation", "rotation"),
|
||||||
|
("o", "opacity", "opacity"),
|
||||||
|
("fc", "fillColor", "fillColor"),
|
||||||
|
("sc", "strokeColor", "strokeColor"),
|
||||||
|
("sw", "strokeWidth", "strokeWidth"),
|
||||||
|
("fh", "fillHue", "fillHue"),
|
||||||
|
("fs", "fillSaturation", "fillSaturation"),
|
||||||
|
("fb", "fillBrightness", "fillBrightness"),
|
||||||
|
("t", "tracking", "tracking"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn conv_for(v: &Value) -> fn(&Value) -> Json {
|
||||||
|
match v {
|
||||||
|
Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => anim::arr,
|
||||||
|
_ => anim::num,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export a text layer's `t` object. Fonts used are added to `fonts`.
|
||||||
|
pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) -> Json {
|
||||||
|
let Some(tg) = layer.props.sub("text") else { return json!({}) };
|
||||||
|
let label = &layer.name;
|
||||||
|
let mut docs = vec![];
|
||||||
|
let mut add_font = |d: &TextDoc| {
|
||||||
|
let n = font_name(&d.font, &d.style);
|
||||||
|
if !fonts.iter().any(|f| f["fName"] == json!(n)) {
|
||||||
|
fonts.push(json!({"fName": n, "fFamily": d.font, "fStyle": d.style, "ascent": 75}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(st) = tg.get("sourceText") {
|
||||||
|
if st.keys.is_empty() {
|
||||||
|
if let Value::Text(d) = &st.value {
|
||||||
|
add_font(d);
|
||||||
|
docs.push(json!({"t": 0, "s": doc_json(d)}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for k in &st.keys {
|
||||||
|
if let Value::Text(d) = &k.value {
|
||||||
|
add_font(d);
|
||||||
|
docs.push(json!({"t": ex.tb.frame(k.time), "s": doc_json(d)}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st.has_expression() {
|
||||||
|
ex.warn(format!("{label} ▸ Source Text: expressions on Source Text are not exported"));
|
||||||
|
}
|
||||||
|
if let Value::Text(d) = &st.value
|
||||||
|
&& ((d.h_scale - 100.0).abs() > 1e-9 || (d.v_scale - 100.0).abs() > 1e-9 || d.faux_bold || d.faux_italic)
|
||||||
|
{
|
||||||
|
ex.warn(format!("{label}: horizontal/vertical scale and faux styles are not part of Lottie text"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut animators = vec![];
|
||||||
|
if let Some(list) = tg.sub("animators") {
|
||||||
|
for a in list.groups() {
|
||||||
|
animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut m = json!({"g": 1, "a": {"a": 0, "k": [0, 0]}});
|
||||||
|
if let Some(more) = tg.sub("moreOptions") {
|
||||||
|
m["g"] = json!(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0) + 1);
|
||||||
|
if let Some(p) = more.get("groupingAlignment") {
|
||||||
|
m["a"] = export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tg.sub("pathOptions").and_then(|p| p.get("path")).is_some_and(|p| p.value.as_enum() > 0) {
|
||||||
|
ex.warn(format!("{label}: text on a path is not exported"));
|
||||||
|
}
|
||||||
|
json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json {
|
||||||
|
let mut props = Map::new();
|
||||||
|
if let Some(pg) = a.sub("properties") {
|
||||||
|
for p in pg.props() {
|
||||||
|
match ANIM_PROPS.iter().find(|(_, _, m)| *m == p.match_id) {
|
||||||
|
Some((key, _, _)) => {
|
||||||
|
let v = export_prop(ex, p, &conv_for(&p.value), &format!("{label} ▸ {}", p.name));
|
||||||
|
props.insert((*key).into(), v);
|
||||||
|
}
|
||||||
|
None if matches!(p.match_id.as_str(), "trackingType") => {}
|
||||||
|
None => ex.warn(format!("{label} ▸ {}: text animator property not supported by Lottie", p.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut sel = json!({"t": 0});
|
||||||
|
let sels: Vec<&PropGroup> = a.sub("selectors").map(|s| s.groups().collect()).unwrap_or_default();
|
||||||
|
if sels.len() > 1 {
|
||||||
|
ex.warn(format!("{label}: only the first selector is exported (Lottie animators have one)"));
|
||||||
|
}
|
||||||
|
if let Some(s) = sels.first() {
|
||||||
|
if s.match_id != "rangeSelector" {
|
||||||
|
ex.warn(format!("{label}: {} is not supported by Lottie", s.name));
|
||||||
|
} else {
|
||||||
|
for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
|
||||||
|
if let Some(p) = s.get(m) {
|
||||||
|
sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(adv) = s.sub("advanced") {
|
||||||
|
let e = |m: &str| adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0) + 1;
|
||||||
|
sel["r"] = json!(e("units"));
|
||||||
|
sel["b"] = json!(e("basedOn"));
|
||||||
|
sel["m"] = json!({"a": 0, "k": e("mode")});
|
||||||
|
sel["sh"] = json!(e("shape"));
|
||||||
|
sel["rn"] = json!(adv.get("randomize").is_some_and(|p| p.value.as_bool()) as u8);
|
||||||
|
for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
|
||||||
|
if let Some(p) = adv.get(m) {
|
||||||
|
sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({"nm": a.name, "s": sel, "a": Json::Object(props)})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import Lottie text data into a text layer's `text` group.
|
||||||
|
pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGroup, fonts: &[(String, String, String)]) {
|
||||||
|
if let Some(list) = t.get("d").and_then(|d| d.get("k")).and_then(Json::as_array)
|
||||||
|
&& let Some(st) = tg.get_mut("sourceText")
|
||||||
|
{
|
||||||
|
let docs: Vec<(f64, TextDoc)> =
|
||||||
|
list.iter().filter_map(|k| Some((k.get("t").and_then(Json::as_f64).unwrap_or(0.0), doc_from(k.get("s")?, fonts)))).collect();
|
||||||
|
if let Some((_, first)) = docs.first() {
|
||||||
|
st.value = Value::Text(Box::new(first.clone()));
|
||||||
|
}
|
||||||
|
if docs.len() > 1 {
|
||||||
|
st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = t.get("m")
|
||||||
|
&& let Some(more) = tg.sub_mut("moreOptions")
|
||||||
|
{
|
||||||
|
if let (Some(g), Some(p)) = (m.get("g").and_then(Json::as_u64), more.get_mut("anchorGrouping")) {
|
||||||
|
p.value = Value::Enum((g.max(1) - 1).min(3) as u32);
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(p)) = (m.get("a"), more.get_mut("groupingAlignment")) {
|
||||||
|
import_prop(im, a, p, &anim::to_vec2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(list) = t.get("a").and_then(Json::as_array) else { return };
|
||||||
|
let mut animators = vec![];
|
||||||
|
for (i, a) in list.iter().enumerate() {
|
||||||
|
let name = a.get("nm").and_then(Json::as_str).map(str::to_string).unwrap_or(format!("Animator {}", i + 1));
|
||||||
|
let props_j = a.get("a").cloned().unwrap_or(json!({}));
|
||||||
|
let mut kinds: Vec<&str> = vec![];
|
||||||
|
for (key, kind, _) in ANIM_PROPS {
|
||||||
|
if props_j.get(*key).is_some() && !kinds.contains(kind) {
|
||||||
|
kinds.push(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let props: Vec<_> = kinds.iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect();
|
||||||
|
let mut g = build::text_animator(ids, &name, props);
|
||||||
|
if let Some(pg) = g.sub_mut("properties") {
|
||||||
|
for (key, _, m) in ANIM_PROPS {
|
||||||
|
let (Some(j), Some(p)) = (props_j.get(*key), pg.get_mut(m)) else { continue };
|
||||||
|
let conv: Box<dyn Fn(&Json) -> Option<Value>> = match &p.value {
|
||||||
|
Value::Vec3(d) => {
|
||||||
|
let z = d[2];
|
||||||
|
Box::new(move |v| anim::to_vec3(v, z))
|
||||||
|
}
|
||||||
|
Value::Vec2(_) => Box::new(anim::to_vec2),
|
||||||
|
Value::Color(_) => Box::new(anim::to_color),
|
||||||
|
_ => Box::new(anim::to_scalar),
|
||||||
|
};
|
||||||
|
import_prop(im, j, p, &*conv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = a.get("s")
|
||||||
|
&& let Some(sel) = g.sub_mut("selectors").and_then(|s| s.groups_mut_first())
|
||||||
|
{
|
||||||
|
for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
|
||||||
|
if let (Some(j), Some(p)) = (s.get(k), sel.get_mut(m)) {
|
||||||
|
import_prop(im, j, p, &anim::to_scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(adv) = sel.sub_mut("advanced") {
|
||||||
|
let mut en = |m: &str, key: &str, max: u32| {
|
||||||
|
let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied());
|
||||||
|
if let (Some(v), Some(p)) = (v, adv.get_mut(m)) {
|
||||||
|
p.value = Value::Enum(((v.round() as i64 - 1).max(0) as u32).min(max));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
en("units", "r", 1);
|
||||||
|
en("basedOn", "b", 3);
|
||||||
|
en("mode", "m", 5);
|
||||||
|
en("shape", "sh", 5);
|
||||||
|
if let (Some(rn), Some(p)) = (s.get("rn").and_then(Json::as_u64), adv.get_mut("randomize")) {
|
||||||
|
p.value = Value::Bool(rn != 0);
|
||||||
|
}
|
||||||
|
for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
|
||||||
|
if let (Some(j), Some(p)) = (s.get(k), adv.get_mut(m)) {
|
||||||
|
import_prop(im, j, p, &anim::to_scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animators.push(g);
|
||||||
|
}
|
||||||
|
if let Some(list) = tg.sub_mut("animators") {
|
||||||
|
list.children.extend(animators.into_iter().map(Node::Group));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait FirstGroup {
|
||||||
|
fn groups_mut_first(&mut self) -> Option<&mut PropGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirstGroup for PropGroup {
|
||||||
|
fn groups_mut_first(&mut self) -> Option<&mut PropGroup> {
|
||||||
|
self.children.iter_mut().find_map(Node::as_group_mut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text converted to glyph shapes: one group with every glyph outline (at layer time 0's
|
||||||
|
/// Source Text), a fill and a stroke, as Lottie shape items.
|
||||||
|
pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec<Json> {
|
||||||
|
let Some(tg) = layer.props.sub("text") else { return vec![] };
|
||||||
|
let Some(st) = tg.get("sourceText") else { return vec![] };
|
||||||
|
let Value::Text(doc) = st.value_at(Tick::ZERO) else { return vec![] };
|
||||||
|
if st.keys.len() > 1 || tg.sub("animators").is_some_and(|a| !a.children.is_empty()) {
|
||||||
|
ex.warn(format!("{}: text exported as static glyph shapes (Source Text keys and animators are baked at time 0)", layer.name));
|
||||||
|
}
|
||||||
|
let lay = effectcraft_text::layout_doc(&doc);
|
||||||
|
let mut items = vec![];
|
||||||
|
for g in &lay.glyphs {
|
||||||
|
let p = kurbo::Affine::translate((g.origin.x, g.origin.y)) * g.path.clone();
|
||||||
|
for sp in effectcraft_path::from_kurbo(&p) {
|
||||||
|
items.push(json!({"ty": "sh", "nm": g.ch.to_string(), "d": 1, "ks": {"a": 0, "k": shapes::shape_json(&sp)}}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let col = |c: [f32; 4]| json!({"a": 0, "k": [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]});
|
||||||
|
let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then(
|
||||||
|
|| json!({"ty": "st", "nm": "Stroke", "c": col(doc.stroke), "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": doc.stroke_width}, "lc": 2, "lj": 2, "ml": 4}),
|
||||||
|
);
|
||||||
|
let fill = doc.apply_fill.then(|| json!({"ty": "fl", "nm": "Fill", "c": col(doc.fill), "o": {"a": 0, "k": 100}, "r": 1}));
|
||||||
|
// Items above draw on top: stroke over fill puts the stroke first.
|
||||||
|
if doc.stroke_over_fill {
|
||||||
|
items.extend(stroke);
|
||||||
|
items.extend(fill);
|
||||||
|
} else {
|
||||||
|
items.extend(fill);
|
||||||
|
items.extend(stroke);
|
||||||
|
}
|
||||||
|
let n = items.len() + 1;
|
||||||
|
items.push(json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}));
|
||||||
|
vec![json!({"ty": "gr", "nm": layer.name, "np": n, "it": items})]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
//! A minimal ZIP writer (stored entries, PKWARE APPNOTE 6.3 local headers + central directory)
|
||||||
|
//! for `.lottie` (dotLottie) archives.
|
||||||
|
|
||||||
|
fn crc32(data: &[u8]) -> u32 {
|
||||||
|
let mut crc = 0xFFFF_FFFFu32;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= b as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!crc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a ZIP archive with the given (name, bytes) entries, uncompressed.
|
||||||
|
pub fn store(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut central = Vec::new();
|
||||||
|
let put16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_le_bytes());
|
||||||
|
let put32 = |v: &mut Vec<u8>, x: u32| v.extend_from_slice(&x.to_le_bytes());
|
||||||
|
for (name, data) in entries {
|
||||||
|
let offset = out.len() as u32;
|
||||||
|
let crc = crc32(data);
|
||||||
|
let n = name.as_bytes();
|
||||||
|
// Local file header.
|
||||||
|
put32(&mut out, 0x0403_4b50);
|
||||||
|
put16(&mut out, 20); // version needed
|
||||||
|
put16(&mut out, 0x0800); // flags: UTF-8 names
|
||||||
|
put16(&mut out, 0); // stored
|
||||||
|
put16(&mut out, 0); // time
|
||||||
|
put16(&mut out, 0x21); // date (1980-01-01)
|
||||||
|
put32(&mut out, crc);
|
||||||
|
put32(&mut out, data.len() as u32);
|
||||||
|
put32(&mut out, data.len() as u32);
|
||||||
|
put16(&mut out, n.len() as u16);
|
||||||
|
put16(&mut out, 0);
|
||||||
|
out.extend_from_slice(n);
|
||||||
|
out.extend_from_slice(data);
|
||||||
|
// Central directory record.
|
||||||
|
put32(&mut central, 0x0201_4b50);
|
||||||
|
put16(&mut central, 20); // made by
|
||||||
|
put16(&mut central, 20);
|
||||||
|
put16(&mut central, 0x0800);
|
||||||
|
put16(&mut central, 0);
|
||||||
|
put16(&mut central, 0);
|
||||||
|
put16(&mut central, 0x21);
|
||||||
|
put32(&mut central, crc);
|
||||||
|
put32(&mut central, data.len() as u32);
|
||||||
|
put32(&mut central, data.len() as u32);
|
||||||
|
put16(&mut central, n.len() as u16);
|
||||||
|
put16(&mut central, 0); // extra
|
||||||
|
put16(&mut central, 0); // comment
|
||||||
|
put16(&mut central, 0); // disk
|
||||||
|
put16(&mut central, 0); // internal attrs
|
||||||
|
put32(&mut central, 0); // external attrs
|
||||||
|
put32(&mut central, offset);
|
||||||
|
central.extend_from_slice(n);
|
||||||
|
}
|
||||||
|
let cd_offset = out.len() as u32;
|
||||||
|
let cd_len = central.len() as u32;
|
||||||
|
out.extend_from_slice(¢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<u8>)> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut i = 0usize;
|
||||||
|
let u16_at = |p: usize| data.get(p..p + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||||
|
let u32_at = |p: usize| data.get(p..p + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||||
|
while u32_at(i) == Some(0x0403_4b50) {
|
||||||
|
let (Some(method), Some(size), Some(nlen), Some(xlen)) = (u16_at(i + 8), u32_at(i + 18), u16_at(i + 26), u16_at(i + 28)) else { break };
|
||||||
|
let name_start = i + 30;
|
||||||
|
let body = name_start + nlen + xlen;
|
||||||
|
let (Some(name), Some(bytes)) = (data.get(name_start..name_start + nlen), data.get(body..body + size)) else { break };
|
||||||
|
if method == 0 {
|
||||||
|
out.push((String::from_utf8_lossy(name).to_string(), bytes.to_vec()));
|
||||||
|
}
|
||||||
|
i = body + size;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn crc_and_roundtrip() {
|
||||||
|
assert_eq!(super::crc32(b"123456789"), 0xCBF4_3926);
|
||||||
|
let z = super::store(&[("a.json".into(), b"{}".to_vec()), ("b/c.txt".into(), b"hello".to_vec())]);
|
||||||
|
let back = super::read_stored(&z);
|
||||||
|
assert_eq!(back.len(), 2);
|
||||||
|
assert_eq!(back[1].0, "b/c.txt");
|
||||||
|
assert_eq!(back[1].1, b"hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -484,6 +484,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||||
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
||||||
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
||||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||||
|
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||||
|
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||||
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
|
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
|
||||||
"file.replaceFootage" => ("path", Ask::Import),
|
"file.replaceFootage" => ("path", Ask::Import),
|
||||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,15 @@ effectcraft-cli get Main '#1' transform/position --time 0.5 main.ecproj --json
|
||||||
effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json
|
effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json
|
||||||
effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json
|
effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json
|
||||||
effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app
|
effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app
|
||||||
|
effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","includeExpressions":true}' main.ecproj --json
|
||||||
|
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie
|
||||||
|
cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an
|
||||||
|
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.
|
||||||
|
`file.importLottie` returns `{comp, items, warnings}` and opens the new composition.
|
||||||
|
|
||||||
`<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`,
|
`<comp>` is an id or name, or `-` for the active comp. `<value>` is JSON (`50`, `[960,540]`,
|
||||||
`"#ff0000"`) or a bare string.
|
`"#ff0000"`) or a bare string.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
||||||
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
|
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
|
||||||
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
|
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
|
||||||
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
|
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
|
||||||
|
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||||
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
|
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
|
||||||
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
|
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
|
||||||
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
|
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
|
||||||
|
|
@ -33,7 +34,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
||||||
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
|
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
|
||||||
|
|
||||||
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
|
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
|
||||||
`media / expr / export → render`, `export → media`, `host → engine`.
|
`media / expr / export → render`, `export → media`, `lottie → format`, `host → engine`.
|
||||||
|
|
||||||
## 2. Time
|
## 2. Time
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue