Merge branch 'worktree-agent-a94e35b1679a89a56'

# Conflicts:
#	README.md
#	crates/engine/Cargo.toml
#	crates/engine/src/commands/mod.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/panels/viewer.rs
This commit is contained in:
Brandon Thomas 2026-10-02 09:26:35 -04:00
commit e1f72413e0
54 changed files with 5315 additions and 76 deletions

View File

@ -17,6 +17,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 |
## Screenshots

18
Cargo.lock generated
View File

@ -1192,6 +1192,7 @@ dependencies = [
"effectcraft-effects",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-lottie",
"effectcraft-path",
"effectcraft-project",
"effectcraft-raster",
@ -1278,6 +1279,23 @@ dependencies = [
"serde_json",
]
[[package]]
name = "effectcraft-lottie"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"kurbo",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-media"
version = "0.1.0"

View File

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

View File

@ -140,6 +140,11 @@ A Render Queue like After Effects', with Render Settings and Output Modules: **H
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
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">
<img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%">
</p>
@ -154,7 +159,8 @@ them:
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
- **Command line:** one-shot calls with JSON output, for example
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save`
or `effectcraft-cli render --comp Main --out main.mp4`.
or `effectcraft-cli render --comp Main --out main.mp4`, or
`effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json"}' main.ecproj`.
- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
and click any widget by its automation id, and take screenshots.
@ -179,8 +185,8 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
## Where it stands
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
paint and roto tools, Lottie and GPU compositing. The web build
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: Roto Brush, Warp
Stabilizer and the 3D camera tracker, Advanced 3D, on-canvas text editing and GPU compositing. The web build
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.

View File

@ -17,7 +17,7 @@ once.
| M8 | Expressions with the After Effects object model | Done |
| M9 | Text and effects: text animators, layer styles, 259 effects incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |

View File

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

View File

@ -69,6 +69,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{name: Default|Standard|Small Screen|Animation|Effects|Motion Tracking|Paint|Text|Minimal|All Panels}",
always
),
fe!("window.saveWorkspace", "Save Changes to this Workspace", ["Window", "Workspace"], None, "{}", always),
fe!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], None, "{name}", always),
fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always),
fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always),
fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always),
fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], None, "{}", has_comp),

View File

@ -256,7 +256,7 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// Masks the command applies to: `mask` (index / uid / name) on `layer`, else selected masks,
/// else every mask of the selected layers.
fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
pub(crate) fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut out = vec![];

View File

@ -0,0 +1,67 @@
//! File ▸ Export ▸ Lottie JSON… and File ▸ Import ▸ Lottie… (`effectcraft-lottie`).
use effectcraft_lottie::{ExportOptions, ImportedImage};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
fn export_lottie(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.exportLottie", "missing `path`"))?.to_string();
let cid = comp_id(s, p)?;
let opts = ExportOptions { include_expressions: b_p(p, "includeExpressions").unwrap_or(false), text_as_shapes: b_p(p, "textAsShapes").unwrap_or(false) };
let services = s.services.clone();
let read = move |f: &str| services.read_file(f).ok();
let res = effectcraft_lottie::export_comp(&s.project, cid, &opts, &read).map_err(|e| EngineError::Other(e.to_string()))?;
let dot = path.to_ascii_lowercase().ends_with(".lottie");
let bytes = if dot {
let stem = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
effectcraft_lottie::to_dotlottie(&res, &stem)
} else {
res.to_string_compact().into_bytes()
};
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let n = res.warnings.len();
s.toast(if n == 0 { format!("Exported Lottie to {path}") } else { format!("Exported Lottie to {path} ({n} warning{})", if n == 1 { "" } else { "s" }) });
Ok(json!({"path": path, "bytes": bytes.len(), "warnings": res.warnings}))
}
fn import_lottie(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.importLottie", "missing `path`"))?.to_string();
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let fp = std::path::Path::new(&path);
let dir = fp.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Lottie".into());
let services = s.services.clone();
// Embedded images are written next to the Lottie file (`<name>_<asset id>.<ext>`).
let mut store = |img: &ImportedImage| -> Option<String> {
let safe: String = img.id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
let file = std::path::Path::new(&dir).join(format!("{stem}_{safe}.{}", img.ext)).to_string_lossy().to_string();
services.write_file(&file, &img.bytes).ok().map(|_| file)
};
let res = s.edit("Import Lottie", None, |proj, st| {
let r = effectcraft_lottie::import(proj, &bytes, &stem, &dir, &mut store).map_err(|e| EngineError::Other(e.to_string()))?;
st.project_selection = vec![r.comp];
Ok(r)
})?;
s.open_comp(res.comp);
if !res.warnings.is_empty() {
s.toast(format!("Imported {path} ({} warnings)", res.warnings.len()));
}
Ok(json!({"comp": res.comp.0, "items": res.items.iter().map(|i| i.0).collect::<Vec<_>>(), "warnings": res.warnings}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.exportLottie",
"Lottie JSON...",
["File", "Export"],
None,
"{comp?, path (.json or .lottie), includeExpressions?: bool, textAsShapes?: bool}",
has_comp,
export_lottie
),
cmd!("file.importLottie", "Lottie...", ["File", "Import"], None, "{path (.json or .lottie)}", always, import_lottie),
]
}

View File

@ -44,9 +44,13 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
let uid = g.uid;
let roto = super::paths::is_roto(g);
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
let r = f(&mut sp)?;
if roto {
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
Ok((uid, r))
})
@ -182,9 +186,14 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
for ((l, m), idx) in &groups {
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
let lt = layer.layer_time(t);
let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue };
let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue };
let roto = super::paths::is_roto(g);
let Some(pr) = g.get_mut("path") else { continue };
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
f(&mut sp, idx);
if roto {
super::paths::roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
}
Ok(())

View File

@ -15,8 +15,10 @@ mod layer;
mod layer_menu;
mod layer_time;
mod link;
mod lottie;
mod mask;
pub mod paint;
mod paths;
mod prop;
pub mod puppet;
mod query;
@ -95,6 +97,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(anim::specs());
v.extend(link::specs());
v.extend(mask::specs());
v.extend(paths::specs());
v.extend(effect::specs());
v.extend(time::specs());
v.extend(render_queue::specs());
@ -104,6 +107,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs());
v.extend(view::specs());
v.extend(file_more::specs());
v.extend(lottie::specs());
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(track::specs());

View File

@ -0,0 +1,478 @@
//! Layer ▸ Mask and Shape Path (RotoBezier, Convert To Bezier Path, Group / Ungroup Shapes, Set
//! First Vertex, Free Transform Points) and Layer ▸ Mask ▸ Motion Blur, Feather Falloff and Hide
//! Locked Masks.
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
use serde_json::{Value, json};
use super::layer_menu::target_masks;
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
use crate::{EngineError, Result, Session, VertexRef, cmd};
// ---------------------------------------------------------------- RotoBezier
/// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its
/// neighbours, a third of the way to them (Catmull-Rom); open ends get none.
pub(crate) fn roto_smooth(sp: &mut ShapePath) {
let n = sp.vertices.len();
sp.in_tangents.resize(n, [0.0; 2]);
sp.out_tangents.resize(n, [0.0; 2]);
for i in 0..n {
let (prev, next) = if sp.closed {
((i + n - 1) % n, (i + 1) % n)
} else if i == 0 || i + 1 == n {
sp.in_tangents[i] = [0.0; 2];
sp.out_tangents[i] = [0.0; 2];
continue;
} else {
(i - 1, i + 1)
};
let (a, b) = (sp.vertices[prev], sp.vertices[next]);
let d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
sp.out_tangents[i] = d;
sp.in_tangents[i] = [-d[0], -d[1]];
}
}
/// Whether a mask uses RotoBezier tangents.
pub(crate) fn is_roto(g: &PropGroup) -> bool {
matches!(g.kind, GroupKind::Mask { roto_bezier: true, .. })
}
/// Apply `f` to a mask path's static value and every key.
fn map_path(g: &mut PropGroup, f: &dyn Fn(&mut ShapePath)) {
let Some(pr) = g.get_mut("path") else { return };
if let KV::Path(sp) = &mut pr.value {
f(sp);
}
for k in &mut pr.keys {
if let KV::Path(sp) = &mut k.value {
f(sp);
}
}
}
/// Mask targets: masks of the selected vertices, else [`target_masks`].
fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
if p.get("mask").is_none() && p.get("layer").is_none() && !s.state.selected_vertices.is_empty() {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let mut v: Vec<(LayerId, Uid)> = s.state.selected_vertices.iter().map(|v| (v.layer, v.mask)).collect();
v.sort();
v.dedup();
return Ok((cid, v));
}
target_masks(s, p)
}
fn edit_mask_groups(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut PropGroup) -> Result<()>) -> Result<Value> {
let (cid, targets) = masks_for(s, p)?;
if targets.is_empty() {
return Err(bad(label, "no masks: select a layer with masks (or mask vertices)"));
}
let n = targets.len();
s.edit(label, None, |proj, _| {
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
if let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) {
f(g)?;
}
}
Ok(())
})?;
Ok(json!({"masks": n}))
}
fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, targets) = masks_for(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let all_on = targets.iter().all(|(l, u)| comp.layer(*l).and_then(|l| l.masks()).and_then(|m| m.find_group(*u)).is_some_and(is_roto));
let on = b_p(p, "value").unwrap_or(!all_on);
edit_mask_groups(s, p, "RotoBezier", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = on;
}
if on {
map_path(g, &roto_smooth);
}
Ok(())
})?;
Ok(json!({"rotoBezier": on}))
}
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = false;
}
Ok(())
})
}
// ---------------------------------------------------------------- mask options
fn mask_motion_blur(s: &mut Session, p: &Value) -> Result<Value> {
let mode = match str_p(p, "mode").unwrap_or("") {
"sameAsLayer" | "same" => MaskMotionBlur::SameAsLayer,
"on" => MaskMotionBlur::On,
"off" => MaskMotionBlur::Off,
_ => return Err(bad("layer.mask.motionBlur", "mode: sameAsLayer|on|off")),
};
edit_mask_groups(s, p, "Mask Motion Blur", |g| {
if let GroupKind::Mask { motion_blur, .. } = &mut g.kind {
*motion_blur = mode;
}
Ok(())
})
}
fn feather_falloff(s: &mut Session, p: &Value) -> Result<Value> {
let mode = match str_p(p, "mode").unwrap_or("") {
"smooth" => FeatherFalloff::Smooth,
"linear" => FeatherFalloff::Linear,
_ => return Err(bad("layer.mask.featherFalloff", "mode: smooth|linear")),
};
edit_mask_groups(s, p, "Mask Feather Falloff", |g| {
if let GroupKind::Mask { feather_falloff, .. } = &mut g.kind {
*feather_falloff = mode;
}
Ok(())
})
}
fn hide_locked(s: &mut Session, p: &Value) -> Result<Value> {
s.state.hide_locked_masks = b_p(p, "value").unwrap_or(!s.state.hide_locked_masks);
Ok(json!(s.state.hide_locked_masks))
}
// ---------------------------------------------------------------- vertices
fn has_one_vertex(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_vertices.len() == 1 { Ok(()) } else { Err("select one mask vertex".into()) }
}
fn set_first_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let v = match (p.get("layer"), p.get("mask"), p.get("index").and_then(Value::as_u64)) {
(Some(_), Some(m), Some(i)) => {
let (_, lid) = layer_p(s, p, "path.setFirstVertex")?;
let mask = m.as_u64().ok_or_else(|| bad("path.setFirstVertex", "`mask` is the mask uid"))?;
VertexRef { layer: lid, mask, index: i as usize }
}
_ => *s
.state
.selected_vertices
.first()
.filter(|_| s.state.selected_vertices.len() == 1)
.ok_or_else(|| bad("path.setFirstVertex", "select one mask vertex"))?,
};
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
s.edit("Set First Vertex", None, |proj, st| {
let l = layer_mut(proj, cid, v.layer)?;
let g = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(v.mask)).ok_or_else(|| bad("path.setFirstVertex", "no such mask"))?;
let k = v.index;
let mut err = None;
map_path(g, &|sp| {
if k < sp.vertices.len() && sp.closed {
sp.vertices.rotate_left(k);
let n = sp.in_tangents.len();
sp.in_tangents.rotate_left(k.min(n));
let n = sp.out_tangents.len();
sp.out_tangents.rotate_left(k.min(n));
}
});
if let Some(KV::Path(sp)) = g.get("path").map(|p| &p.value)
&& !sp.closed
{
err = Some(bad("path.setFirstVertex", "open paths always start at their first vertex"));
}
if let Some(e) = err {
return Err(e);
}
st.selected_vertices = vec![VertexRef { index: 0, ..v }];
Ok(())
})?;
Ok(json!({"first": v.index}))
}
/// Free Transform Points: scale / rotate / move the selected vertices (or whole masks) about an
/// anchor (default: the centre of their bounding box), at the current time.
fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
let scale = match p.get("scale") {
Some(Value::Number(n)) => {
let k = n.as_f64().unwrap_or(100.0) / 100.0;
[k, k]
}
Some(Value::Array(a)) if a.len() >= 2 => [a[0].as_f64().unwrap_or(100.0) / 100.0, a[1].as_f64().unwrap_or(100.0) / 100.0],
_ => [1.0, 1.0],
};
let rot = f_p(p, "rotation").unwrap_or(0.0).to_radians();
let off =
p.get("offset").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let off = off.unwrap_or([0.0; 2]);
let anchor =
p.get("anchor").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
if scale == [1.0, 1.0] && rot == 0.0 && off == [0.0, 0.0] {
return Err(bad("path.freeTransform", "pass scale (% or [x%, y%]), rotation (deg) and/or offset [dx, dy]; anchor? [x, y]"));
}
let (cid, targets) = masks_for(s, p)?;
let selected: Vec<VertexRef> = s.state.selected_vertices.clone();
let t = s.time();
let (c, sn) = (rot.cos(), rot.sin());
let lin = |v: [f64; 2]| {
let x = v[0] * scale[0];
let y = v[1] * scale[1];
[x * c - y * sn, x * sn + y * c]
};
let n = s.edit("Free Transform Points", None, |proj, _| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
let roto = is_roto(g);
let Some(pr) = g.get_mut("path") else { continue };
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
let idx: Vec<usize> = {
let sel: Vec<usize> =
selected.iter().filter(|v| v.layer == *lid && v.mask == *uid).map(|v| v.index).filter(|i| *i < sp.vertices.len()).collect();
if sel.is_empty() { (0..sp.vertices.len()).collect() } else { sel }
};
if idx.is_empty() {
continue;
}
let a = anchor.unwrap_or_else(|| {
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for &i in &idx {
let v = sp.vertices[i];
lo = [lo[0].min(v[0]), lo[1].min(v[1])];
hi = [hi[0].max(v[0]), hi[1].max(v[1])];
}
[(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0]
});
for &i in &idx {
let v = sp.vertices[i];
let q = lin([v[0] - a[0], v[1] - a[1]]);
sp.vertices[i] = [q[0] + a[0] + off[0], q[1] + a[1] + off[1]];
if let Some(tn) = sp.in_tangents.get_mut(i) {
*tn = lin(*tn);
}
if let Some(tn) = sp.out_tangents.get_mut(i) {
*tn = lin(*tn);
}
n += 1;
}
if roto {
roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
}
Ok(n)
})?;
Ok(json!({"vertices": n}))
}
// ---------------------------------------------------------------- shape groups
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
fn selected_shape_items(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut out = vec![];
if let Some(Value::Array(a)) = p.get("items") {
let (_, lid) = layer_p(s, p, "path.groupShapes")?;
out.extend(a.iter().filter_map(Value::as_u64).map(|u| (lid, u)));
} else {
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid).filter(|l| matches!(l.source, LayerSource::Shape)) else { continue };
if l.props
.sub("contents")
.and_then(|c| c.find_group(*uid))
.is_some_and(|g| g.uid != l.props.sub("contents").map(|c| c.uid).unwrap_or(0) && SHAPE_ITEMS.contains(&g.match_id.as_str()))
{
out.push((*lid, *uid));
}
}
}
Ok((cid, out))
}
fn has_shape_items(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let comp = s.active_comp().ok_or("no composition")?;
let ok = s.state.selected_props.iter().any(|(l, u)| {
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| SHAPE_ITEMS.contains(&g.match_id.as_str()))
});
if ok { Ok(()) } else { Err("select shape items (Contents ▸ …) in the timeline".into()) }
}
fn group_shapes(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, items) = selected_shape_items(s, p)?;
let Some(lid) = items.first().map(|x| x.0) else { return Err(bad("path.groupShapes", "select shape items to group")) };
let uids: Vec<Uid> = items.iter().filter(|(l, _)| *l == lid).map(|(_, u)| *u).collect();
let g = s.edit("Group Shapes", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.groupShapes", "not a shape layer"))?;
let parent = contents.parent_of_mut(uids[0]).ok_or_else(|| bad("path.groupShapes", "no such shape item"))?;
if !uids.iter().all(|u| parent.children.iter().any(|c| c.uid() == *u)) {
return Err(bad("path.groupShapes", "the shape items must be in the same group"));
}
let at = parent.children.iter().position(|c| uids.contains(&c.uid())).unwrap_or(0);
let mut moved = vec![];
parent.children.retain(|c| {
if uids.contains(&c.uid()) {
moved.push(c.clone());
false
} else {
true
}
});
let n = parent.children.iter().filter(|c| c.match_id() == "group").count() + 1;
let groups: Vec<PropGroup> = moved.into_iter().filter_map(|c| if let Node::Group(g) = c { Some(g) } else { None }).collect();
let g = build::shape_group(&mut Ids(&mut next), &format!("Group {n}"), groups);
let uid = g.uid;
parent.children.insert(at.min(parent.children.len()), Node::Group(g));
proj.next_id = next;
st.selected_props = vec![(lid, uid)];
Ok(uid)
})?;
Ok(json!({"group": g}))
}
/// Add `d` to every positional value in a shape item subtree (shape positions, path vertices,
/// group positions, gradient points, twist centres) — static values and keys.
fn offset_items(g: &mut PropGroup, d: [f64; 2]) {
let shift = |v: &mut KV| match v {
KV::Vec2(a) => {
a[0] += d[0];
a[1] += d[1];
}
KV::Path(sp) => {
for q in &mut sp.vertices {
q[0] += d[0];
q[1] += d[1];
}
}
_ => {}
};
let props: &[&str] = match g.match_id.as_str() {
"rect" | "ellipse" | "star" => &["position"],
"path" => &["path"],
"gfill" | "gstroke" => &["start", "end"],
"twist" => &["center"],
_ => &[],
};
for m in props {
if let Some(pr) = g.get_mut(m) {
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
}
}
}
if g.match_id == "group"
&& let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position"))
{
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
}
}
}
fn ungroup_shapes(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, items) = selected_shape_items(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let groups: Vec<(LayerId, Uid)> = items
.into_iter()
.filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| g.match_id == "group"))
.collect();
if groups.is_empty() {
return Err(bad("path.ungroupShapes", "select a shape group to ungroup"));
}
let n = groups.len();
s.edit("Ungroup Shapes", None, |proj, st| {
let mut sel = vec![];
for (lid, uid) in &groups {
let l = layer_mut(proj, cid, *lid)?;
let Some(contents) = l.props.sub_mut("contents") else { continue };
let Some(parent) = contents.parent_of_mut(*uid) else { continue };
let Some(i) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
let Node::Group(g) = parent.children.remove(i) else { continue };
// The group's transform must be a plain (static) move: it is baked into the items.
let tr = g.sub("transform");
let get = |m: &str| tr.and_then(|t| t.get(m));
let animated = tr.is_some_and(|t| t.any_animated());
let v2 = |m: &str, d: [f64; 2]| get(m).map(|p| p.value.as_vec2()).unwrap_or(d);
let f = |m: &str, d: f64| get(m).map(|p| p.value.as_f64()).unwrap_or(d);
let (a, pos, sc) = (v2("anchor", [0.0; 2]), v2("position", [0.0; 2]), v2("scale", [100.0; 2]));
if animated || sc != [100.0, 100.0] || f("rotation", 0.0) != 0.0 || f("skew", 0.0) != 0.0 || f("opacity", 100.0) != 100.0 {
return Err(bad(
"path.ungroupShapes",
format!("`{}` has a scale, rotation, skew, opacity or animated transform: reset it before ungrouping", g.name),
));
}
let d = [pos[0] - a[0], pos[1] - a[1]];
let mut inner: Vec<Node> = g.sub("contents").map(|c| c.children.clone()).unwrap_or_default();
for c in &mut inner {
if let Node::Group(ig) = c {
if d != [0.0, 0.0] {
offset_items(ig, d);
}
sel.push((*lid, ig.uid));
}
}
for (k, c) in inner.into_iter().enumerate() {
parent.children.insert(i + k, c);
}
}
st.selected_props = sel;
Ok(())
})?;
Ok(json!({"ungrouped": n}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier),
cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier),
cmd!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes),
cmd!(
"path.ungroupShapes",
"Ungroup Shapes",
["Layer", "Mask and Shape Path"],
Some("Cmd+Shift+G"),
"{layer?, items?: [group uid]}",
has_shape_items,
ungroup_shapes
),
cmd!(
"path.setFirstVertex",
"Set First Vertex",
["Layer", "Mask and Shape Path"],
None,
"{layer?, mask?: uid, index?}",
has_one_vertex,
set_first_vertex
),
cmd!(
"path.freeTransform",
"Free Transform Points",
["Layer", "Mask and Shape Path"],
None,
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
has_layers,
free_transform
),
cmd!("layer.mask.motionBlur", "Motion Blur", [], None, "{layer?, mask?, mode: sameAsLayer|on|off}", has_layers, mask_motion_blur),
cmd!("layer.mask.featherFalloff", "Feather Falloff", [], None, "{layer?, mask?, mode: smooth|linear}", has_layers, feather_falloff),
cmd!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], None, "{value?}", has_comp, hide_locked),
]
}

View File

@ -5,12 +5,12 @@
//! `index` (1-based, the # column).
use effectcraft_project::render_queue::{
AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
AudioOutput, Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, str_p};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
@ -195,6 +195,24 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
Ok(any)
}
/// `module` (1-based; 1 = the first output module).
fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result<usize> {
let n = p.get("module").and_then(Value::as_u64).unwrap_or(1) as usize;
let have = s.project.render_queue[i].extra_outputs.len() + 1;
if n == 0 || n > have {
return Err(bad(cmd, format!("module must be 1..={have}")));
}
Ok(n - 1)
}
fn module_ref(it: &RenderQueueItem, m: usize) -> &OutputModule {
if m == 0 { &it.output } else { &it.extra_outputs[m - 1] }
}
fn module_mut(it: &mut RenderQueueItem, m: usize) -> &mut OutputModule {
if m == 0 { &mut it.output } else { &mut it.extra_outputs[m - 1] }
}
/// A finished item that is edited goes back to Queued (AE re-queues on change).
fn requeue(it: &mut RenderQueueItem) {
if it.render && !matches!(it.status, RenderStatus::Rendering) {
@ -215,6 +233,18 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
o.insert("outputPath".into(), json!(s.resolve_output(it)));
o.insert("renderSettingsSummary".into(), json!(it.settings.summary()));
o.insert("outputModuleSummary".into(), json!(it.output.summary()));
let extra: Vec<Value> = it
.extra_outputs
.iter()
.map(|om| {
let mut c = it.clone();
c.output = om.clone();
json!({"summary": om.summary(), "outputPath": s.resolve_output(&c)})
})
.collect();
o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1));
o.insert("extraOutputs".into(), json!(extra));
o.insert("postRenderAction".into(), json!(it.post_render.label()));
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
o.insert("frames".into(), json!(frames));
@ -285,13 +315,14 @@ fn set_render_settings(s: &mut Session, p: &Value) -> Result<Value> {
fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutputModule")?;
let mut om = s.project.render_queue[i].output.clone();
let m = module_p(s, i, p, "renderQueue.setOutputModule")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
}
s.edit("Output Module Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.output = om;
*module_mut(it, m) = om;
requeue(it);
Ok(())
})?;
@ -301,11 +332,12 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutput")?;
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
let mut om = s.project.render_queue[i].output.clone();
let m = module_p(s, i, p, "renderQueue.setOutput")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
s.edit("Output To", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.output = om;
*module_mut(it, m) = om;
requeue(it);
Ok(())
})?;
@ -386,6 +418,89 @@ fn formats(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>()}))
}
/// `item?|index?`, else the last item (the queue's newest entry).
fn item_or_last(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
if p.get("item").is_some() || p.get("index").is_some() {
return item_index(s, p, cmd);
}
s.project.render_queue.len().checked_sub(1).ok_or_else(|| bad(cmd, "the render queue is empty"))
}
/// Composition ▸ Add Output Module: another output module on a render item (the same frames are
/// encoded once more).
fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_or_last(s, p, "render.addOutputModule")?;
let it = &s.project.render_queue[i];
let n = it.extra_outputs.len() + 2;
let mut om = it.output.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
om = OutputModule::for_format(f);
}
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
// Same name with a module suffix so the files don't collide.
om.output = match om.output.rfind('.') {
Some(dot) => format!("{}_{n}{}", &om.output[..dot], &om.output[dot..]),
None => format!("{}_{n}", om.output),
};
}
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut om, &op, "render.addOutputModule")?;
s.edit("Add Output Module", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.extra_outputs.push(om);
requeue(it);
Ok(())
})?;
let mut v = item_json(s, &s.project.render_queue[i], i);
v["module"] = json!(n);
Ok(v)
}
/// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render
/// action imports the result and replaces the comp's uses.
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.channels = Channels::Rgba;
it.output.output = "[compName]_prerender.[fileExtension]".into();
apply_output(&mut it.output, p, "render.preRender")?;
it.post_render = PostRenderAction::ImportAndReplace;
let id = s.edit("Pre-render", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let idx = s.project.render_queue.len() - 1;
let mut v = item_json(s, &s.project.render_queue[idx], idx);
v["item"] = json!(id);
Ok(v)
}
/// Composition ▸ Save Current Preview…: render the work area of the comp straight to a movie file.
fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("render.saveCurrentPreview", "missing `path`"))?.to_string();
let cid = comp_id(s, p)?;
let format = OutputFormat::from_path(&path).unwrap_or(OutputFormat::H264);
let exporter = s.exporter.clone().ok_or_else(|| EngineError::Other("export is not available in this build".into()))?;
if !exporter.formats().contains(&format) {
return Err(EngineError::Other(format!("{} export is not available", format.label())));
}
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job = crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), item: &item, path: &path };
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
Ok(serde_json::to_value(&r).unwrap_or_default())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -413,7 +528,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
has_items,
set_output_module
),
@ -422,10 +537,21 @@ pub fn specs() -> Vec<CommandSpec> {
"Output To...",
[],
None,
"{item?|index?, path: file path or template like [compName].[fileExtension]}",
"{item?|index?, module?: n, path: file path or template like [compName].[fileExtension]}",
has_items,
set_output
),
cmd!(
"render.addOutputModule",
"Add Output Module",
["Composition"],
None,
"{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, audio?}",
has_items,
add_output_module
),
cmd!("render.preRender", "Pre-render...", ["Composition"], None, "{comp?, output?: path|template, format?}", can_add, pre_render),
cmd!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], None, "{comp?, path}", has_comp, save_current_preview),
cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item),
cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate),
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),

View File

@ -21,9 +21,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("app.hideOthers", "Hide Others", [], "{}"),
stub!("app.showAll", "Show All", [], "{}"),
// Render queue / export (export milestone).
stub!("render.addOutputModule", "Add Output Module", ["Composition"], "{}"),
stub!("render.preRender", "Pre-render...", ["Composition"], "{}"),
stub!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], "{path}"),
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
// Proxies (no proxy model yet).
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
@ -37,15 +34,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
stub!("layer.mask.motionBlur", "Motion Blur", [], "{mode: sameAsLayer|on|off}"),
stub!("layer.mask.featherFalloff", "Feather Falloff", [], "{mode: smooth|linear}"),
stub!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], "{}"),
stub!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], "{}"),
stub!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], "{}"),
stub!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], "Cmd+G", "{}"),
stub!("path.ungroupShapes", "Ungroup Shapes", ["Layer", "Mask and Shape Path"], "Cmd+Shift+G", "{}"),
stub!("path.setFirstVertex", "Set First Vertex", ["Layer", "Mask and Shape Path"], "{}"),
stub!("path.freeTransform", "Free Transform Points", ["Layer", "Mask and Shape Path"], "{}"),
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
@ -54,7 +42,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
// Cameras / lights / materials / 3D views (3D milestone).
stub!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], "{}"),
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
@ -67,11 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"),
stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"),
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
stub!("view.layout", "Switch View Layout", [], "{views: 1|2|4}"),
stub!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.lookAtSelected", "Look at Selected Layers", ["View"], "Cmd+Alt+Shift+\\", "{}"),
stub!("view.lookAtAll", "Look at All Layers", ["View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
// Color management.
@ -85,9 +68,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"),
stub!("track.mask", "Track Mask", ["Animation"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.saveWorkspace", "Save Changes to this Workspace", ["Window", "Workspace"], "{}"),
stub!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], "{name}"),
stub!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], "{}"),
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]
}

View File

@ -529,6 +529,122 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
// ---------------------------------------------------------------- look at / camera from view
/// World-space points spanning the layers' content at comp time `t` (content-box corners
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);
match effectcraft_render::content_bounds(&ctx, l) {
Some([x0, y0, x1, y1]) => {
for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] {
out.push(m.apply(vec3(x, y, 0.0)));
}
}
None => out.push(m.apply(Vec3::ZERO)),
}
}
out
}
fn look_at(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
let cmd = if all { "view.lookAtAll" } else { "view.lookAtSelected" };
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ids: Vec<LayerId> = if all {
comp.layers.iter().filter(|l| l.is_active_at(t) && !l.is_camera() && !l.is_light()).map(|l| l.id).collect()
} else {
let (_, sel) = super::layers_p(s, p)?;
sel
};
if ids.is_empty() {
return Err(bad(cmd, if all { "the composition has no visible layers" } else { "select layers first" }));
}
let pts = layer_points(s, cid, &ids, t);
let (mut lo, mut hi) = (vec3(f64::MAX, f64::MAX, f64::MAX), vec3(f64::MIN, f64::MIN, f64::MIN));
for q in &pts {
lo = vec3(lo.x.min(q.x), lo.y.min(q.y), lo.z.min(q.z));
hi = vec3(hi.x.max(q.x), hi.y.max(q.y), hi.z.max(q.z));
}
let c = (lo + hi) * 0.5;
let r = ((hi - lo).length() * 0.5).max(1.0);
let (w, h) = (comp.width as f64, comp.height as f64);
let fit = w.min(h);
let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
if !comp.has_3d() {
// 2D: frame the layers in the viewer (zoom and pan to their comp-space bounds).
let rect = [lo.x, lo.y, hi.x, hi.y];
super::frontend(s, "view.lookAt", &json!({"rect": rect}))?;
return Ok(json!({"rect": rect}));
}
if cur != View3D::ActiveCamera {
let vs = views(s, cid);
let mut vc = vs.cam(cur, w, h);
let fwd = (Vec3::from(vc.poi) - Vec3::from(vc.eye)).normalize();
if vc.ortho {
vc.zoom = fit / (2.2 * r);
vc.eye = camera::arr(c - fwd * 10_000.0);
} else {
vc.eye = camera::arr(c - fwd * (vc.zoom * r * 2.2 / fit));
}
vc.poi = camera::arr(c);
vs.cams.insert(cur, vc);
return Ok(json!({"view": cur.id(), "eye": vc.eye, "poi": vc.poi, "zoom": vc.zoom}));
}
let cam = comp.active_camera(t).map(|l| l.id).ok_or_else(|| bad(cmd, "the comp has no camera layer: switch to a 3D view (View ▸ Switch 3D View)"))?;
s.edit(if all { "Look at All Layers" } else { "Look at Selected Layers" }, None, |proj, _| {
let l = layer_mut(proj, cid, cam)?;
let lt = l.layer_time(t);
let pos = Vec3::from(get_at(l, "transform/position", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, -1000.0]));
let zoom = get_at(l, "cameraOptions/zoom", lt).map(|v| v.as_f64()).unwrap_or(1000.0);
let two = camera::is_two_node(l);
let fwd = if two {
let poi = Vec3::from(get_at(l, "transform/poi", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0]));
(poi - pos).normalize()
} else {
let o = get_at(l, "transform/orientation", lt).map(|v| v.as_vec3()).unwrap_or([0.0; 3]);
Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)).normalize()
};
let eye = c - fwd * (zoom * r * 2.2 / fit);
set_at(l, "transform/position", lt, KV::Vec3(camera::arr(eye)));
if two {
set_at(l, "transform/poi", lt, KV::Vec3(camera::arr(c)));
}
Ok(json!({"layer": cam.0, "position": camera::arr(eye), "poi": camera::arr(c)}))
})
}
/// Layer ▸ Camera ▸ Create Camera from 3D View: a two-node camera matching the current 3D view.
fn camera_from_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let (w, h) = (comp.width as f64, comp.height as f64);
let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
if cur == View3D::ActiveCamera {
return Err(bad("camera.fromView", "switch to a 3D view first (View ▸ Switch 3D View)"));
}
let vc = s.state.views3d.get(&cid).cloned().unwrap_or_default().cam(cur, w, h);
let poi = Vec3::from(vc.poi);
let fwd = (poi - Vec3::from(vc.eye)).normalize();
let zoom = if vc.ortho { effectcraft_geom::default_camera_zoom(w) } else { vc.zoom };
let eye = if vc.ortho { poi - fwd * zoom } else { Vec3::from(vc.eye) };
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let name = comp.unique_layer_name("Camera 1");
let params = json!({"position": camera::arr(eye), "poi": camera::arr(poi), "zoom": zoom});
let id = s.edit("Create Camera from 3D View", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Camera, (comp.width, comp.height), None);
apply_camera(&mut l, &comp, &params, t, "camera.fromView")?;
insert_layer(proj, st, cid, l)
})?;
switch_view(s, cid, View3D::ActiveCamera);
Ok(json!({"layer": id.0, "from": cur.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -596,6 +712,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.3d.custom2", "Custom View 2", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom2),
cmd!("view.3d.custom3", "Custom View 3", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom3),
cmd!("view.3d.last", "Switch to Last 3D View", ["View"], None, "{comp?}", has_comp, last_view),
cmd!("view.lookAtSelected", "Look at Selected Layers", ["View"], Some("Cmd+Alt+Shift+\\"), "{comp?, layers?}", super::has_layers, |s, p| look_at(
s, p, false
)),
cmd!("view.lookAtAll", "Look at All Layers", ["View"], None, "{comp?}", has_comp, |s, p| look_at(s, p, true)),
cmd!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], None, "{comp?}", has_comp, camera_from_view),
cmd!("view.reset3DView", "Reset 3D View", ["View"], None, "{comp?}", has_comp, reset_view),
cmd!("view.set3DViewCamera", "Set 3D View Camera", [], None, "{view, eye? [x,y,z], poi? [x,y,z], zoom?}", has_comp, set_view_camera),
cmd!(

View File

@ -75,12 +75,28 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.region_of_interest))
}
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
if !matches!(n, 1 | 2 | 4) {
return Err(bad("view.layout", "views: 1|2|4"));
}
s.state.view_layout = n as u8;
Ok(json!({"views": n}))
}
fn share_view_options(s: &mut Session, p: &Value) -> Result<Value> {
s.state.share_view_options = super::b_p(p, "value").unwrap_or(!s.state.share_view_options);
Ok(json!(s.state.share_view_options))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest),
]
}

View File

@ -169,6 +169,19 @@ pub struct EditorState {
/// Puppet tool options for new meshes.
#[serde(default)]
pub puppet: commands::puppet::PuppetOptions,
/// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer.
#[serde(default = "one_view")]
pub view_layout: u8,
/// View ▸ Switch View Layout ▸ Share View Options (grid, guides… in every view).
#[serde(default)]
pub share_view_options: bool,
/// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines).
#[serde(default)]
pub hide_locked_masks: bool,
}
fn one_view() -> u8 {
1
}
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
@ -251,7 +264,7 @@ impl Default for Session {
revision: 0,
saved_revision: 0,
path: None,
state: EditorState { snapping: true, ..Default::default() },
state: EditorState { snapping: true, view_layout: 1, ..Default::default() },
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
@ -487,7 +500,7 @@ impl Session {
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
self.state = EditorState { snapping: true, ..Default::default() };
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
self.path = path;
self.bump();
self.saved_revision = self.revision;
@ -507,10 +520,14 @@ mod tests_3d;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_text;

View File

@ -162,6 +162,21 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
})
}
"layer.markersLock" => Some(layer?.markers_locked),
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
Some(match command {
"path.rotoBezier" => roto_bezier,
"layer.mask.motionBlur" => matches!(
(pstr("mode")?, motion_blur),
("sameAsLayer", MaskMotionBlur::SameAsLayer) | ("on", MaskMotionBlur::On) | ("off", MaskMotionBlur::Off)
),
_ => matches!((pstr("mode")?, feather_falloff), ("smooth", FeatherFalloff::Smooth) | ("linear", FeatherFalloff::Linear)),
})
}
_ => None,
}
}
@ -320,8 +335,10 @@ File
Multiple Files... | file.importMultiple
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
Lottie... | file.importLottie
Export
Add to Render Queue | renderQueue.add
Lottie JSON... | file.exportLottie
---
Find | app.find
---

View File

@ -10,8 +10,8 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, Project};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
@ -121,7 +121,8 @@ struct Work {
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
exporter: Arc<dyn Exporter>,
items: Vec<(RenderQueueItem, String)>,
/// Per queue item: one (item with that output module, resolved path) per output module.
items: Vec<Vec<(RenderQueueItem, String)>>,
}
fn unix_now() -> u64 {
@ -134,7 +135,8 @@ fn run_work(w: Work, shared: &JobShared) {
let mut s = lock(&shared.state);
s.items_total = w.items.len();
}
for (k, (item, path)) in w.items.iter().enumerate() {
for (k, modules) in w.items.iter().enumerate() {
let Some((item, _)) = modules.first() else { continue };
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
continue;
@ -149,16 +151,29 @@ fn run_work(w: Work, shared: &JobShared) {
s.items_done = k;
s.remaining = None;
}
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item, path };
let r = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
!shared.cancel.load(Ordering::Relaxed)
});
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
!shared.cancel.load(Ordering::Relaxed)
});
match rr {
Ok(res) if mi == 0 => r = Ok(res),
Ok(_) => {}
Err(e) => {
r = Err(e);
break;
}
}
}
let seconds = t1.elapsed().as_secs_f64();
let up = match r {
Ok(res) => ItemUpdate::Finished { id: item.id, status: RenderStatus::Done, seconds, output: Some(res.path) },
@ -240,11 +255,24 @@ impl Session {
let mut items = Vec::new();
let mut needs_output = Vec::new();
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
match self.resolve_output(it) {
Some(p) if formats.contains(&it.output.format) => items.push((it.clone(), p)),
Some(_) => needs_output.push((it.id, RenderStatus::Failed(format!("{} export is not available", it.output.format.label())))),
None if self.project.comp(it.comp).is_none() => needs_output.push((it.id, RenderStatus::Failed("composition missing".into()))),
None => needs_output.push((it.id, RenderStatus::NeedsOutput)),
let mut modules = vec![];
let mut problem = None;
for om in it.output_modules() {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.contains(&om.format) => modules.push((m, p)),
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
None => problem = Some(RenderStatus::NeedsOutput),
}
if problem.is_some() {
break;
}
}
match problem {
Some(st) => needs_output.push((it.id, st)),
None => items.push(modules),
}
}
if !needs_output.is_empty() {
@ -259,7 +287,7 @@ impl Session {
if items.is_empty() {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().map(|(i, _)| i.id).collect();
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
@ -292,6 +320,7 @@ impl Session {
let updates = std::mem::take(&mut *lock(&job.shared.updates));
let finished = job.is_finished();
let changed = !updates.is_empty();
let mut post: Vec<(u64, String)> = vec![];
if changed {
let p = Arc::make_mut(&mut self.project);
for u in &updates {
@ -310,6 +339,11 @@ impl Session {
if output.is_some() {
i.last_output = output.clone();
}
if let (RenderStatus::Done, Some(o)) = (status, output)
&& !i.post_render.is_none()
{
post.push((*id, o.clone()));
}
}
}
}
@ -333,9 +367,50 @@ impl Session {
};
self.events.push(Event::Toast { message: msg, error: failed > 0 });
}
for (id, path) in post {
if let Err(e) = self.post_render_action(id, &path) {
self.events.push(Event::Toast { message: format!("Post-render action: {e}"), error: true });
}
}
changed || finished
}
/// Run an item's Post-Render Action: import the rendered file, and for Import & Replace
/// Usage swap every layer that used the rendered composition to the new footage (one undo
/// step).
pub fn post_render_action(&mut self, id: u64, path: &str) -> Result<Option<ItemId>, String> {
let Some(item) = self.project.render_queue.iter().find(|i| i.id == id).cloned() else { return Ok(None) };
if item.post_render.is_none() {
return Ok(None);
}
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
let footage = importer.probe(path)?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let replace = item.post_render == PostRenderAction::ImportAndReplace;
let comp = item.comp;
let label = if replace { "Pre-render: Import & Replace Usage" } else { "Post-Render: Import" };
self.edit(label, None, |proj, st| {
let fid = proj.add_item(&name, effectcraft_color::Label::Aqua, None, ItemKind::Footage(footage));
if replace {
let ids: Vec<ItemId> = proj.comps().map(|(i, _)| *i).filter(|i| *i != comp).collect();
for cid in ids {
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)));
if !uses {
continue;
}
if let Some(c) = proj.comp_mut(cid) {
for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) {
l.source = LayerSource::Footage { item: fid };
}
}
}
}
st.project_selection = vec![fid];
Ok(Some(fid))
})
.map_err(|e| e.to_string())
}
/// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel.
pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option<ItemId> {
if p.get("comp").is_some() {

View File

@ -0,0 +1,60 @@
//! File ▸ Export ▸ Lottie JSON… / File ▸ Import ▸ Lottie… through the command registry.
use serde_json::{Value, json};
use crate::Session;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-lottie-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 100, "height": 60})).unwrap();
s.execute("layer.newShape", json!({})).ok();
s
}
#[test]
fn export_and_import_lottie_commands() {
let mut s = session();
let n_layers = s.active_comp().unwrap().layers.len();
s.execute("prop.addKey", json!({"layer": "#1", "path": "transform/opacity", "time": 0.0, "value": 0.0})).unwrap();
s.execute("prop.addKey", json!({"layer": "#1", "path": "transform/opacity", "time": 1.0, "value": 100.0})).unwrap();
let path = tmp("main.json");
let r = s.execute_checked("file.exportLottie", json!({"path": path, "includeExpressions": true})).unwrap();
assert!(r["bytes"].as_u64().unwrap() > 100);
let doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(doc["w"], json!(320));
assert_eq!(doc["layers"].as_array().unwrap().len(), n_layers);
// Import as a new comp (undoable).
let before = s.project.items.len();
let r = s.execute_checked("file.importLottie", json!({"path": path})).unwrap();
let cid = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
let c = s.project.comp(cid).unwrap();
assert_eq!(c.layers.len(), n_layers);
let op = c.layers[0].props.prop("transform/opacity").unwrap();
assert_eq!(op.keys.len(), 2);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.items.len(), before);
// dotLottie.
let dot = tmp("main.lottie");
s.execute("file.exportLottie", json!({"path": dot})).unwrap();
assert!(std::fs::read(&dot).unwrap().starts_with(b"PK"));
let r = s.execute("file.importLottie", json!({"path": dot})).unwrap();
assert!(r["comp"].as_u64().is_some());
// Unknown parameters are rejected for agents.
assert!(s.execute_checked("file.exportLottie", json!({"path": path, "bogus": 1})).is_err());
}
#[test]
fn lottie_menu_entries_exist() {
let entries = crate::menus::entries();
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
assert_eq!(find("file.exportLottie"), Some(("File > Export".into(), "Lottie JSON...".into())));
assert_eq!(find("file.importLottie"), Some(("File > Import".into(), "Lottie...".into())));
}

View File

@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.create", "track.motion"] {
for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}

View File

@ -0,0 +1,336 @@
//! Menu entries that used to be disabled stubs (M3.8 burn-down): mask and shape path commands,
//! mask options, Look at Layers, view layouts, Create Camera from 3D View, output modules,
//! Pre-render and Save Current Preview.
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction};
use effectcraft_project::{FeatherFalloff, Footage, FootageKind, GroupKind, LayerId, LayerSource, MaskMotionBlur};
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Event, ExportJob, ExportResult, Exporter, Importer, Session};
fn comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
/// A solid with one closed square mask; returns (layer id, mask uid).
fn masked(s: &mut Session) -> (u64, u64) {
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap();
s.execute("mask.new", json!({"layer": l, "vertices": [[100, 100], [300, 100], [300, 200], [100, 200]], "closed": true})).unwrap();
let m = layer(s, l).masks().unwrap().groups().next().unwrap().uid;
(l, m)
}
fn mask_path(s: &Session, l: u64, m: u64) -> effectcraft_keyframe::ShapePath {
let ly = layer(s, l);
let g = ly.masks().unwrap().find_group(m).unwrap();
g.get("path").unwrap().value.as_path().unwrap().clone()
}
fn mask_kind(s: &Session, l: u64) -> GroupKind {
layer(s, l).masks().unwrap().groups().next().unwrap().kind.clone()
}
#[test]
fn roto_bezier_and_convert_to_bezier() {
let mut s = comp();
let (l, m) = masked(&mut s);
assert!(mask_path(&s, l, m).out_tangents.iter().all(|t| *t == [0.0, 0.0]));
s.execute("path.rotoBezier", json!({"layer": l})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. }));
let p = mask_path(&s, l, m);
// Vertex 0 at (100,100): neighbours (100,200) and (300,100) → tangent (200,-100)/6.
assert!((p.out_tangents[0][0] - 200.0 / 6.0).abs() < 1e-9 && (p.out_tangents[0][1] + 100.0 / 6.0).abs() < 1e-9, "{:?}", p.out_tangents);
s.execute("layer.select", json!({"layers": [l]})).unwrap();
assert_eq!(crate::menus::checked(&s, "path.rotoBezier", &json!({})), Some(true));
// Moving a vertex keeps the tangents automatic.
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap();
s.execute("mask.moveVertices", json!({"delta": [50, 50]})).unwrap();
let q = mask_path(&s, l, m);
assert_ne!(q.out_tangents[1], p.out_tangents[1]);
s.execute("path.convertToBezier", json!({"layer": l})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: false, .. }));
assert_eq!(mask_path(&s, l, m), q, "convert keeps the tangents");
s.execute("edit.undo", json!({})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { roto_bezier: true, .. }));
}
#[test]
fn set_first_vertex_and_free_transform() {
let mut s = comp();
let (l, m) = masked(&mut s);
s.state.selected_vertices.clear();
assert!(!s.is_enabled("path.setFirstVertex"));
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap();
assert!(s.is_enabled("path.setFirstVertex"));
s.execute("path.setFirstVertex", json!({})).unwrap();
let p = mask_path(&s, l, m);
assert_eq!(p.vertices[0], [300.0, 200.0]);
assert_eq!(p.vertices[3], [300.0, 100.0]);
assert_eq!(s.state.selected_vertices[0].index, 0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, l, m).vertices[0], [100.0, 100.0]);
// Free Transform the whole mask: scale 50 % about its centre, then move.
s.state.selected_vertices.clear();
s.execute("path.freeTransform", json!({"layer": l, "scale": 50, "offset": [10, 0]})).unwrap();
let p = mask_path(&s, l, m);
assert_eq!(p.vertices[0], [160.0, 125.0]);
assert_eq!(p.vertices[2], [260.0, 175.0]);
// Rotation of selected vertices only.
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap();
s.execute("path.freeTransform", json!({"rotation": 180})).unwrap();
let q = mask_path(&s, l, m);
assert!((q.vertices[0][0] - 260.0).abs() < 1e-9 && (q.vertices[1][0] - 160.0).abs() < 1e-9);
assert_eq!(q.vertices[2], p.vertices[2]);
assert!(s.execute("path.freeTransform", json!({})).is_err());
}
#[test]
fn group_and_ungroup_shapes() {
let mut s = comp();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let contents = layer(&s, l).props.sub("contents").unwrap().clone();
let outer = contents.groups().next().unwrap().clone();
let inner: Vec<u64> = outer.sub("contents").unwrap().groups().map(|g| g.uid).collect();
assert!(inner.len() >= 2, "{outer:?}");
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.state.selected_props = inner.iter().map(|u| (LayerId(l), *u)).collect();
assert!(s.is_enabled("path.groupShapes"));
let g = s.execute("path.groupShapes", json!({})).unwrap()["group"].as_u64().unwrap();
let ly = layer(&s, l);
let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap();
let kids: Vec<&str> = o.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
assert_eq!(kids, ["group"]);
assert_eq!(o.find_group(g).unwrap().sub("contents").unwrap().groups().count(), inner.len());
let before = s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap();
// Move the new group, then ungroup: the offset is baked into its items.
let gp = ly.props.sub("contents").unwrap().find_group(g).unwrap().sub("transform").unwrap().get("position").unwrap().uid;
s.edit("t", None, |proj, _| {
let cid = proj.comps().next().map(|(i, _)| *i).unwrap();
let l = proj.comp_mut(cid).unwrap().layer_mut(LayerId(l)).unwrap();
l.props.find_mut(gp).unwrap().value = KV::Vec2([0.0, 0.0]);
Ok(())
})
.unwrap();
s.state.selected_props = vec![(LayerId(l), g)];
s.execute("path.ungroupShapes", json!({})).unwrap();
let ly = layer(&s, l);
let o = ly.props.sub("contents").unwrap().find_group(outer.uid).unwrap();
assert_eq!(o.sub("contents").unwrap().groups().count(), inner.len());
assert_eq!(s.render_rgba8(s.active_comp_id().unwrap(), Tick::ZERO, 200).unwrap(), before);
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, l).props.find_group(g).is_some());
}
#[test]
fn mask_motion_blur_feather_falloff_and_hide_locked() {
let mut s = comp();
let (l, _) = masked(&mut s);
s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 40})).unwrap();
let cid = s.active_comp_id().unwrap();
let smooth = s.render_rgba8(cid, Tick::ZERO, 0).unwrap();
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }));
let linear = s.render_rgba8(cid, Tick::ZERO, 0).unwrap();
assert_ne!(smooth, linear);
s.execute("layer.select", json!({"layers": [l]})).unwrap();
assert_eq!(crate::menus::checked(&s, "layer.mask.featherFalloff", &json!({"mode": "linear"})), Some(true));
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. }));
assert!(s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "sometimes"})).is_err());
// Mask motion blur blurs an animated mask path.
s.execute("layer.mask.set", json!({"layer": l, "field": "feather", "value": 0})).unwrap();
let still = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
s.execute("path.freeTransform", json!({"layer": l, "offset": [80, 0]})).unwrap();
let blurred = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap();
let soft = |img: &(u32, u32, Vec<u8>)| img.2.chunks(4).filter(|p| p[0] > 10 && p[0] < 245).count();
assert!(soft(&blurred) > soft(&still) + 100, "{} vs {}", soft(&blurred), soft(&still));
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
let sharp = s.render_rgba8(cid, Tick::from_seconds_f64(0.5), 0).unwrap();
assert!(soft(&sharp) < soft(&blurred));
s.execute("edit.undo", json!({})).unwrap();
assert!(matches!(mask_kind(&s, l), GroupKind::Mask { motion_blur: MaskMotionBlur::On, .. }));
// Hide Locked Masks is a viewer toggle.
assert!(s.execute("layer.mask.hideLocked", json!({})).unwrap().as_bool().unwrap());
assert_eq!(crate::menus::checked(&s, "layer.mask.hideLocked", &json!({})), Some(true));
}
#[test]
fn look_at_layers_2d_and_3d() {
let mut s = comp();
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [50, 60]})).unwrap();
s.drain_events();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
let r = s.execute("view.lookAtSelected", json!({})).unwrap();
assert_eq!(r["rect"], json!([30.0, 50.0, 70.0, 70.0]));
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "view.lookAt")));
// 3D: a custom view frames the layers; the active camera view moves the camera (undoable).
s.execute("layer.setSwitch", json!({"layer": a, "switch": "threeD", "value": true})).unwrap();
s.execute("layer.newCamera", json!({})).unwrap();
let cid = s.active_comp_id().unwrap();
s.execute("view.3d.custom1", json!({})).unwrap();
let r = s.execute("view.lookAtAll", json!({})).unwrap();
let poi = r["poi"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!((poi[0] - 50.0).abs() < 1e-6 && (poi[1] - 60.0).abs() < 1e-6, "{r}");
s.execute("view.3d.activeCamera", json!({})).unwrap();
let cam = s.active_comp().unwrap().layers.iter().find(|l| l.is_camera()).unwrap().id;
let before = s.active_comp().unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value.clone();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("view.lookAtSelected", json!({})).unwrap();
let c = s.project.comp(cid).unwrap().layer(cam).unwrap();
assert_eq!(c.props.prop("transform/poi").unwrap().value.as_vec3()[..2], [50.0, 60.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.comp(cid).unwrap().layer(cam).unwrap().props.prop("transform/position").unwrap().value, before);
}
#[test]
fn camera_from_view_and_view_layout() {
let mut s = comp();
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
assert!(s.execute("camera.fromView", json!({})).is_err(), "needs a 3D view");
s.execute("view.3d.custom2", json!({})).unwrap();
let cid = s.active_comp_id().unwrap();
let vc = s.state.views3d[&cid].cam(effectcraft_render::three_d::View3D::Custom2, 400.0, 300.0);
let r = s.execute("camera.fromView", json!({})).unwrap();
let cam = layer(&s, r["layer"].as_u64().unwrap());
assert!(cam.is_camera());
let pos = cam.props.prop("transform/position").unwrap().value.as_vec3();
assert!((0..3).all(|i| (pos[i] - vc.eye[i]).abs() < 1e-6), "{pos:?} {:?}", vc.eye);
assert_eq!(s.state.views3d[&cid].current, effectcraft_render::three_d::View3D::ActiveCamera);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_comp().unwrap().layers.iter().any(|l| l.is_camera()));
// Switch View Layout.
assert_eq!(s.state.view_layout, 1);
s.execute("view.layout", json!({"views": 2})).unwrap();
assert_eq!(s.state.view_layout, 2);
assert_eq!(crate::menus::checked(&s, "view.layout", &json!({"views": 2})), Some(true));
assert!(s.execute("view.layout", json!({"views": 3})).is_err());
assert!(s.execute("view.shareViewOptions", json!({})).unwrap().as_bool().unwrap());
}
struct MockExporter {
log: Mutex<Vec<String>>,
}
impl Exporter for MockExporter {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.log.lock().unwrap().push(format!("{:?}:{}", job.item.output.format, job.path));
Ok(ExportResult { path: job.path.to_string(), frames: 1, ..Default::default() })
}
}
struct MockImporter;
impl Importer for MockImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage {
path: path.into(),
kind: FootageKind::Video,
width: 400,
height: 300,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::from_seconds_f64(4.0),
has_video: true,
has_audio: false,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: "mock".into(),
missing: false,
sequence: vec![],
})
}
}
fn rq() -> (Session, Arc<MockExporter>) {
let mut s = comp();
let ex = Arc::new(MockExporter { log: Default::default() });
s.exporter = Some(ex.clone());
s.importer = Some(Arc::new(MockImporter));
(s, ex)
}
#[test]
fn add_output_module_encodes_twice() {
let (mut s, ex) = rq();
assert!(!s.is_enabled("render.addOutputModule"));
s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap();
let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
assert_eq!(r["module"], 2);
assert_eq!(r["outputModules"], 2);
let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string();
assert!(extra.ends_with(".png"), "{extra}");
// Module 2 is editable on its own.
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap();
assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err());
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(*ex.log.lock().unwrap(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]);
assert_eq!(s.project.render_queue[0].status.label(), "Done");
assert_eq!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/om/main.mp4"));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit");
}
#[test]
fn pre_render_imports_and_replaces() {
let (mut s, ex) = rq();
let inner = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.open_comp(inner);
let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap();
assert_eq!(r["postRenderAction"], "Import & Replace Usage");
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace);
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(ex.log.lock().unwrap().len(), 1);
let l = &s.project.comp(outer).unwrap().layers[0];
let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) };
assert!(matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/pre/inner.mov"));
s.execute("edit.undo", json!({})).unwrap();
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
}
#[test]
fn save_current_preview_writes_a_movie() {
let (mut s, ex) = rq();
let r = s.execute("render.saveCurrentPreview", json!({"path": "/tmp/prev/clip.mov"})).unwrap();
assert_eq!(r["path"], "/tmp/prev/clip.mov");
assert_eq!(*ex.log.lock().unwrap(), vec!["ProRes:/tmp/prev/clip.mov".to_string()]);
assert!(s.project.render_queue.is_empty(), "not queued");
}
#[test]
fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
let mut s = comp();
for id in ["view.layout", "view.lookAtAll", "camera.fromView", "render.saveCurrentPreview", "render.preRender", "layer.mask.hideLocked"] {
assert!(s.is_enabled(id), "{id}");
}
for id in ["window.saveWorkspace", "window.saveWorkspaceAs", "window.editWorkspaces"] {
s.execute(id, json!({"name": "Mine"})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] {
assert!(!s.is_enabled(id), "{id}");
}
}

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

@ -0,0 +1,25 @@
[package]
name = "effectcraft-lottie"
description = "Lottie JSON import and export: compositions, precomps, shape/solid/null/image/text layers, keyframes with temporal and spatial easing, masks, track mattes and a few effects"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
effectcraft-render = { workspace = true }

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

@ -0,0 +1,376 @@
//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes.
//!
//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic
//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x`
//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease
//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and
//! value change `Δv`,
//!
//! ```text
//! o.x = influence_out o.y = speed_out · influence_out · d / Δv
//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv
//! ```
//!
//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the
//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys
//! set `h: 1`.
use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value};
use effectcraft_project::{Expression, Property};
use serde_json::{Value as Json, json};
use crate::Timebase;
/// Export state shared by the whole document.
pub(crate) struct Ex {
pub tb: Timebase,
pub include_expressions: bool,
pub warnings: Vec<String>,
}
impl Ex {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
}
/// Import state.
pub(crate) struct Im {
pub tb: Timebase,
pub warnings: Vec<String>,
}
impl Im {
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
}
fn secs(t: effectcraft_time::Tick) -> f64 {
t.seconds()
}
fn r6(v: f64) -> f64 {
if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }
}
/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array).
fn wrap(j: Json) -> Json {
match j {
Json::Array(_) => j,
other => json!([other]),
}
}
fn is_spatial(p: &Property) -> bool {
p.spatial && matches!(p.value, Value::Vec2(_) | Value::Vec3(_))
}
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
/// `label` names the property in warnings.
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
if ex.include_expressions {
out["x"] = json!(e.text);
} else {
ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)"));
}
}
out
}
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
let keys = &p.keys;
let spatial = is_spatial(p);
let mut out = Vec::with_capacity(keys.len());
for (i, k) in keys.iter().enumerate() {
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
if i + 1 == keys.len() {
if k.out_interp == Interp::Hold || p.hold_only {
o["h"] = json!(1);
}
out.push(o);
continue;
}
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
o["h"] = json!(1);
out.push(o);
continue;
}
let (ox, oy, ix, iy) = segment_ease(keys, i, spatial);
o["o"] = json!({"x": ox, "y": oy});
o["i"] = json!({"x": ix, "y": iy});
if spatial {
let (_, to) = kf::spatial_tangents(keys, i);
let (ti, _) = kf::spatial_tangents(keys, i + 1);
let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::<Vec<_>>());
}
out.push(o);
}
out
}
/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension.
fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let (a, b) = (&keys[i], &keys[i + 1]);
let dur = (secs(b.time) - secs(a.time)).max(1e-12);
let ca = a.value.components();
let cb = b.value.components();
let numeric = !ca.is_empty() && ca.len() == cb.len();
let dims = if spatial || !numeric { 1 } else { ca.len() };
let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]);
for d in 0..dims {
let (dv, eo, ei) = if spatial {
let len = kf::spatial_segment_length(keys, i);
(len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
} else if numeric {
(cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
} else {
// Paths, gradients: overall progress 0 → 1.
let side = |k: &Keyframe, out: bool| {
let interp = if out { k.out_interp } else { k.in_interp };
let list = if out { &k.out_ease } else { &k.in_ease };
if interp == Interp::Bezier { list.first().copied().unwrap_or_default() } else { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } }
};
(1.0, Some(side(a, true)), Some(side(b, false)))
};
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
ox.push(r6(i0));
oy.push(r6(y0));
ix.push(r6(1.0 - i1));
iy.push(r6(y1));
}
(ox, oy, ix, iy)
}
// ---------------------------------------------------------------- import
/// Whether a Lottie property value is keyframed.
pub(crate) fn is_animated(j: &Json) -> bool {
if j.get("a").and_then(Json::as_i64) == Some(1) {
return true;
}
j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some())
}
/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array).
fn comp(j: &Json, field: &str, d: usize) -> Option<f64> {
match j.get(field)? {
Json::Number(n) => n.as_f64(),
Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64),
_ => None,
}
}
fn vec3_of(j: Option<&Json>) -> [f64; 3] {
let a = j.and_then(Json::as_array);
let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0);
[g(0), g(1), g(2)]
}
/// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie
/// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of
/// the property's type.
pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option<Value>) {
if !j.is_object() {
if let Some(v) = conv(j) {
p.value = v;
}
return;
}
if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) {
p.expr = Some(Expression { text: x.to_string(), enabled: true });
}
let Some(k) = j.get("k") else { return };
if !is_animated(j) {
if let Some(v) = conv(k) {
p.value = v;
}
return;
}
let Some(list) = k.as_array() else { return };
let spatial = is_spatial(p);
let mut keys: Vec<Keyframe> = Vec::with_capacity(list.len());
let mut kept: Vec<&Json> = Vec::with_capacity(list.len());
let mut prev_end: Option<&Json> = None;
for kj in list {
let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0);
let sv = kj.get("s").or(prev_end);
prev_end = kj.get("e");
let Some(v) = sv.and_then(conv) else { continue };
let mut key = Keyframe::new(im.tb.tick(t), v);
key.spatial_auto = false;
if p.hold_only || !key.value.interpolates() {
key = key.hold();
}
keys.push(key);
kept.push(kj);
}
if keys.is_empty() {
return;
}
let n = keys.len();
for i in 0..n.saturating_sub(1) {
let Some(kj) = kept.get(i) else { break };
if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only {
keys[i].out_interp = Interp::Hold;
keys[i + 1].in_interp = Interp::Hold;
continue;
}
if spatial {
let to = vec3_of(kj.get("to"));
let ti = vec3_of(kj.get("ti"));
keys[i].spatial_out = to;
keys[i + 1].spatial_in = ti;
}
}
// Eases need the spatial tangents (arc length), so they come second.
for i in 0..n.saturating_sub(1) {
let Some(kj) = kept.get(i) else { break };
if keys[i].out_interp == Interp::Hold {
continue;
}
let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else {
keys[i].out_interp = Interp::Linear;
keys[i + 1].in_interp = Interp::Linear;
continue;
};
let dur = (secs(keys[i + 1].time) - secs(keys[i].time)).max(1e-12);
let ca = keys[i].value.components();
let cb = keys[i + 1].value.components();
let numeric = !ca.is_empty() && ca.len() == cb.len();
let dims = if spatial || !numeric { 1 } else { ca.len() };
let mut linear = true;
let mut outs = vec![];
let mut ins = vec![];
for d in 0..dims {
let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0);
let oy = comp(o, "y", d).unwrap_or(ox);
let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0);
let iy = comp(ii, "y", d).unwrap_or(ix);
if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 {
linear = false;
}
let dv = if spatial {
kf::spatial_segment_length(&keys, i)
} else if numeric {
cb[d] - ca[d]
} else {
1.0
};
let inf_o = ox.max(0.001);
let inf_i = (1.0 - ix).max(0.001);
outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o });
ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i });
}
if linear {
keys[i].out_interp = Interp::Linear;
keys[i + 1].in_interp = Interp::Linear;
} else {
keys[i].out_interp = Interp::Bezier;
keys[i].out_ease = outs;
keys[i + 1].in_interp = Interp::Bezier;
keys[i + 1].in_ease = ins;
}
}
p.value = keys[0].value.clone();
p.keys = keys;
}
// ---------------------------------------------------------------- value converters
pub(crate) fn num(v: &Value) -> Json {
json!(r6(v.as_f64()))
}
pub(crate) fn arr(v: &Value) -> Json {
match v {
Value::Scalar(x) => json!([r6(*x)]),
_ => json!(v.components().iter().map(|x| r6(*x)).collect::<Vec<_>>()),
}
}
/// Vec2 (first two components of any vector).
pub(crate) fn arr2(v: &Value) -> Json {
let c = v.components();
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0))])
}
/// Vec3 (padded with `z`).
pub(crate) fn arr3(v: &Value) -> Json {
let c = v.components();
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))])
}
/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`).
pub(crate) fn nums(j: &Json) -> Option<Vec<f64>> {
match j {
Json::Number(n) => Some(vec![n.as_f64()?]),
Json::Array(a) => {
// `[[x, y]]`-style wrapping.
if a.len() == 1
&& let Some(inner @ Json::Array(_)) = a.first()
{
return nums(inner);
}
a.iter().map(Json::as_f64).collect()
}
_ => None,
}
}
pub(crate) fn to_scalar(j: &Json) -> Option<Value> {
nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
}
pub(crate) fn to_vec2(j: &Json) -> Option<Value> {
let v = nums(j)?;
Some(Value::Vec2([*v.first()?, *v.get(1).unwrap_or(&0.0)]))
}
/// Vec3 with a default third component.
pub(crate) fn to_vec3(j: &Json, z: f64) -> Option<Value> {
let v = nums(j)?;
Some(Value::Vec3([*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&z)]))
}
pub(crate) fn to_color(j: &Json) -> Option<Value> {
let v = nums(j)?;
let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)];
// Some files use 0–255.
if c[..3].iter().any(|x| *x > 1.0) {
for x in &mut c[..3] {
*x /= 255.0;
}
}
Some(Value::Color(c))
}
pub(crate) fn to_bool(j: &Json) -> Option<Value> {
match j {
Json::Bool(b) => Some(Value::Bool(*b)),
_ => to_scalar(j).map(|v| Value::Bool(v.as_f64() != 0.0)),
}
}
/// Lottie 1-based enum → 0-based popup index.
pub(crate) fn to_enum1(j: &Json) -> Option<Value> {
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
}

View File

@ -0,0 +1,69 @@
//! Standard base64 (RFC 4648 §4) for embedded image assets.
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
for i in 0..4 {
if i <= chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
pub fn decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => return None,
} as u32)
};
let clean: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
for chunk in clean.chunks(4) {
let mut n = 0u32;
for (i, &c) in chunk.iter().enumerate() {
n |= val(c)? << (18 - 6 * i);
}
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
match chunk.len() {
4 => out.extend_from_slice(&bytes),
3 => out.extend_from_slice(&bytes[..2]),
2 => out.push(bytes[0]),
_ => return None,
}
}
Some(out)
}
/// Split a `data:<mime>;base64,<payload>` URI into (mime, bytes).
pub fn data_uri(s: &str) -> Option<(String, Vec<u8>)> {
let rest = s.strip_prefix("data:")?;
let (head, payload) = rest.split_once(',')?;
let mime = head.strip_suffix(";base64")?;
Some((mime.to_string(), decode(payload)?))
}
#[cfg(test)]
mod tests {
#[test]
fn roundtrip() {
for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] {
let e = super::encode(s.as_bytes());
assert_eq!(super::decode(&e).unwrap(), s.as_bytes());
}
assert_eq!(super::encode(b"foobar"), "Zm9vYmFy");
assert_eq!(super::encode(b"fo"), "Zm8=");
}
}

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

@ -0,0 +1,513 @@
//! EffectCraft composition → Lottie document.
use std::collections::BTreeSet;
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, PropGroup};
use serde_json::{Map, Value as Json, json};
use crate::anim::{self, Ex, export_prop};
use crate::{LottieError, Timebase, shapes, text};
/// Export settings.
#[derive(Clone, Debug, Default)]
pub struct ExportOptions {
/// Export expressions as Lottie `x` strings (players that run expressions evaluate them).
pub include_expressions: bool,
/// Export text layers as glyph shapes (exact look, no font needed) instead of text layers.
pub text_as_shapes: bool,
}
/// An exported Lottie document and what could not be expressed.
#[derive(Clone, Debug)]
pub struct ExportResult {
pub json: Json,
pub warnings: Vec<String>,
}
impl ExportResult {
pub fn to_string_compact(&self) -> String {
serde_json::to_string(&self.json).unwrap_or_default()
}
}
/// One supported effect: Lottie effect type and its parameter list in Lottie order:
/// (EffectCraft param id or "" for a Lottie-only param, Lottie value type, scale to Lottie,
/// constant value for Lottie-only params).
pub(crate) struct FxMap {
pub id: &'static str,
ty: u32,
params: &'static [(&'static str, u32, f64, f64)],
}
/// The effects Lottie players implement.
const EFFECTS: &[FxMap] = &[
FxMap { id: "ec.blur.gaussian", ty: 29, params: &[("blurriness", 0, 1.0, 0.0), ("dimensions", 7, 1.0, 0.0), ("repeatEdge", 4, 1.0, 0.0)] },
FxMap { id: "ec.color.tint", ty: 20, params: &[("black", 2, 1.0, 0.0), ("white", 2, 1.0, 0.0), ("amount", 0, 1.0, 0.0)] },
FxMap {
id: "ec.color.tritone",
ty: 23,
params: &[("highlights", 2, 1.0, 0.0), ("midtones", 2, 1.0, 0.0), ("shadows", 2, 1.0, 0.0), ("blend", 0, 1.0, 0.0)],
},
FxMap {
id: "ec.perspective.dropshadow",
ty: 25,
params: &[
("color", 2, 1.0, 0.0),
("opacity", 0, 2.55, 0.0),
("direction", 1, 1.0, 0.0),
("distance", 0, 1.0, 0.0),
("softness", 0, 1.0, 0.0),
("shadowOnly", 4, 1.0, 0.0),
],
},
FxMap {
id: "ec.generate.fill",
ty: 21,
params: &[
("", 10, 1.0, 0.0),
("", 4, 1.0, 1.0),
("color", 2, 1.0, 0.0),
("invert", 4, 1.0, 0.0),
("", 0, 1.0, 0.0),
("", 0, 1.0, 0.0),
("opacity", 0, 0.01, 0.0),
],
},
FxMap { id: "ec.control.slider", ty: 5, params: &[("slider", 0, 1.0, 0.0)] },
FxMap { id: "ec.control.angle", ty: 5, params: &[("angle", 1, 1.0, 0.0)] },
FxMap { id: "ec.control.color", ty: 5, params: &[("color", 2, 1.0, 0.0)] },
FxMap { id: "ec.control.point", ty: 5, params: &[("point", 3, 1.0, 0.0)] },
FxMap { id: "ec.control.checkbox", ty: 5, params: &[("checkbox", 4, 1.0, 0.0)] },
];
pub(crate) fn fx_by_id(id: &str) -> Option<&'static FxMap> {
EFFECTS.iter().find(|f| f.id == id)
}
pub(crate) fn fx_by_type(ty: u64, inner_ty: Option<u64>) -> Option<&'static FxMap> {
if ty == 5 {
return EFFECTS.iter().find(|f| f.ty == 5 && Some(f.params[0].1 as u64) == inner_ty);
}
EFFECTS.iter().find(|f| f.ty as u64 == ty)
}
pub(crate) fn fx_params(f: &FxMap) -> &'static [(&'static str, u32, f64, f64)] {
f.params
}
fn mask_mode(m: MaskMode) -> &'static str {
match m {
MaskMode::None => "n",
MaskMode::Add => "a",
MaskMode::Subtract => "s",
MaskMode::Intersect => "i",
MaskMode::Lighten => "l",
MaskMode::Darken => "d",
MaskMode::Difference => "f",
}
}
pub(crate) fn mask_mode_from(s: &str) -> MaskMode {
match s {
"n" => MaskMode::None,
"s" => MaskMode::Subtract,
"i" => MaskMode::Intersect,
"l" => MaskMode::Lighten,
"d" => MaskMode::Darken,
"f" => MaskMode::Difference,
_ => MaskMode::Add,
}
}
pub(crate) fn matte_code(k: MatteKind) -> u32 {
match k {
MatteKind::Alpha => 1,
MatteKind::AlphaInverted => 2,
MatteKind::Luma => 3,
MatteKind::LumaInverted => 4,
}
}
struct Doc<'a> {
project: &'a Project,
opts: &'a ExportOptions,
read: &'a dyn Fn(&str) -> Option<Vec<u8>>,
ex: Ex,
assets: Vec<Json>,
done: BTreeSet<String>,
fonts: Vec<Json>,
any_3d: bool,
}
fn hex(c: [f32; 3]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
}
fn mime_of(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".webp") {
"image/webp"
} else if lower.ends_with(".gif") {
"image/gif"
} else {
"image/png"
}
}
impl Doc<'_> {
fn frame(&self, t: effectcraft_time::Tick) -> f64 {
self.ex.tb.frame(t)
}
fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option<Json> {
let p = g.get(m)?;
Some(export_prop(&mut self.ex, p, conv, &format!("{label} ▸ {}", p.name)))
}
fn transform(&mut self, l: &Layer) -> Json {
let mut ks = Map::new();
let Some(tr) = l.transform() else { return json!({}) };
let label = format!("{} ▸ Transform", l.name);
let three = l.is_3d();
if let Some(a) = self.prop(tr, "anchor", &anim::arr3, &label) {
ks.insert("a".into(), a);
}
if tr.get("positionX").is_some() {
let mut p = json!({"s": true});
for (k, m) in [("x", "positionX"), ("y", "positionY"), ("z", "positionZ")] {
if (k != "z" || three)
&& let Some(v) = self.prop(tr, m, &anim::num, &label)
{
p[k] = v;
}
}
ks.insert("p".into(), p);
} else if let Some(p) = self.prop(tr, "position", &anim::arr3, &label) {
ks.insert("p".into(), p);
}
if let Some(s) = self.prop(tr, "scale", &anim::arr3, &label) {
ks.insert("s".into(), s);
}
if three {
for (k, m) in [("rx", "rotationX"), ("ry", "rotationY"), ("rz", "rotation")] {
if let Some(v) = self.prop(tr, m, &anim::num, &label) {
ks.insert(k.into(), v);
}
}
if let Some(v) = self.prop(tr, "orientation", &anim::arr3, &label) {
ks.insert("or".into(), v);
}
} else if let Some(r) = self.prop(tr, "rotation", &anim::num, &label) {
ks.insert("r".into(), r);
}
if let Some(o) = self.prop(tr, "opacity", &anim::num, &label) {
ks.insert("o".into(), o);
}
Json::Object(ks)
}
fn masks(&mut self, l: &Layer) -> Vec<Json> {
let Some(masks) = l.masks() else { return vec![] };
let mut out = vec![];
for m in masks.groups() {
let GroupKind::Mask { mode, inverted, .. } = m.kind else { continue };
let label = format!("{} ▸ {}", l.name, m.name);
let mut o = json!({"nm": m.name, "mode": mask_mode(mode), "inv": inverted});
if let Some(v) = self.prop(m, "path", &shapes::path_value, &label) {
o["pt"] = v;
}
if let Some(v) = self.prop(m, "opacity", &anim::num, &label) {
o["o"] = v;
}
if let Some(v) = self.prop(m, "expansion", &anim::num, &label) {
o["x"] = v;
}
if let Some(f) = m.get("feather") {
if f.is_animated() || f.value.components().iter().any(|v| *v != 0.0) {
self.ex.warn(format!("{label}: mask feather is exported as `f` (not every Lottie player renders it)"));
}
o["f"] = export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather"));
}
if !m.enabled {
o["mode"] = json!("n");
}
out.push(o);
}
out
}
fn effects(&mut self, l: &Layer) -> Vec<Json> {
let Some(fx) = l.effects() else { return vec![] };
let mut out = vec![];
for (i, g) in fx.groups().enumerate() {
let GroupKind::Effect { effect } = &g.kind else { continue };
let label = format!("{} ▸ {}", l.name, g.name);
let Some(map) = fx_by_id(effect) else {
self.ex.warn(format!("{label}: effect not supported by Lottie (skipped)"));
continue;
};
let mut params = vec![];
for (pi, (m, ty, scale, konst)) in map.params.iter().enumerate() {
let (nm, v) = match g.get(m).filter(|_| !m.is_empty()) {
Some(p) => {
let (ty, scale) = (*ty, *scale);
let conv = move |v: &Value| -> Json {
match ty {
2 => anim::arr(v),
3 => anim::arr2(v),
4 => json!(v.as_bool() as u8),
7 => json!(v.as_enum() + 1),
_ => json!(v.as_f64() * scale),
}
};
(p.name.clone(), export_prop(&mut self.ex, p, &conv, &format!("{label} ▸ {}", p.name)))
}
None => (format!("Param {}", pi + 1), json!({"a": 0, "k": konst})),
};
params.push(json!({"ty": ty, "nm": nm, "mn": m, "ix": pi + 1, "v": v}));
}
out.push(json!({"ty": map.ty, "nm": g.name, "mn": effect, "ix": i + 1, "en": g.enabled as u8, "np": params.len(), "ef": params}));
}
out
}
fn image_asset(&mut self, item: ItemId, l: &Layer) -> Option<String> {
let id = format!("image_{}", item.0);
if self.done.contains(&id) {
return Some(id);
}
let it = self.project.item(item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
let Some(bytes) = (self.read)(&f.path) else {
self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path));
return None;
};
let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes));
self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1}));
self.done.insert(id.clone());
Some(id)
}
fn precomp_asset(&mut self, item: ItemId) -> Option<String> {
let id = format!("comp_{}", item.0);
if self.done.contains(&id) {
return Some(id);
}
let it = self.project.item(item)?;
let c = it.as_comp()?;
self.done.insert(id.clone());
let layers = self.layers(c);
self.assets.push(json!({"id": id, "nm": it.name, "fr": c.frame_rate.as_f64(), "w": c.width, "h": c.height, "layers": layers}));
Some(id)
}
fn layers(&mut self, comp: &Comp) -> Vec<Json> {
let mattes: BTreeSet<_> = comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
let mut out = vec![];
for (idx, l) in comp.layers.iter().enumerate() {
out.push(self.layer(comp, idx, l, mattes.contains(&l.id)));
}
out
}
fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> Json {
let name = l.name.clone();
let mut o = Map::new();
o.insert("ind".into(), json!(idx + 1));
o.insert("nm".into(), json!(name));
let three = l.is_3d();
if three {
self.any_3d = true;
if !l.is_camera() && !l.is_light() {
self.ex.warn(format!("{name}: 3D layer exported with ddd: 1 (most Lottie players render 2D only)"));
}
}
o.insert("ddd".into(), json!(three as u8));
let mut hidden = !l.switches.video || l.switches.guide;
let mut ty = 3;
match &l.source {
LayerSource::Shape => {
ty = 4;
let items = l.props.sub("contents").map(|c| shapes::export_items(&mut self.ex, c, &name)).unwrap_or_default();
o.insert("shapes".into(), json!(items));
}
LayerSource::Text => {
if self.opts.text_as_shapes {
ty = 4;
let items = text::glyph_shapes(&mut self.ex, l);
o.insert("shapes".into(), json!(items));
} else {
ty = 5;
let t = text::export_text(&mut self.ex, l, &mut self.fonts);
o.insert("t".into(), t);
}
}
LayerSource::Solid { item } => {
let sol = self.project.item(*item).and_then(|i| if let ItemKind::Solid(s) = &i.kind { Some(s.clone()) } else { None });
match sol {
Some(s) if !l.switches.adjustment => {
ty = 1;
o.insert("sc".into(), json!(hex(s.color)));
o.insert("sw".into(), json!(s.width));
o.insert("sh".into(), json!(s.height));
}
_ => {
self.ex.warn(format!("{name}: adjustment layers are not part of Lottie (exported as a null)"));
hidden = true;
}
}
}
LayerSource::Comp { item } => {
if let Some(id) = self.precomp_asset(*item) {
ty = 0;
let (w, h) = self.project.comp(*item).map(|c| (c.width, c.height)).unwrap_or((comp.width, comp.height));
o.insert("refId".into(), json!(id));
o.insert("w".into(), json!(w));
o.insert("h".into(), json!(h));
if let Some(tm) = l.props.get("timeRemap") {
let v = export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap"));
o.insert("tm".into(), v);
}
}
}
LayerSource::Footage { item } => {
let kind = self.project.item(*item).and_then(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.has_video)) } else { None });
match kind {
Some((FootageKind::Still, true)) => {
if let Some(id) = self.image_asset(*item, l) {
ty = 2;
o.insert("refId".into(), json!(id));
} else {
hidden = true;
}
}
Some((FootageKind::Audio, _)) | Some((_, false)) => {
self.ex.warn(format!("{name}: audio is not part of Lottie (layer exported as a hidden null)"));
hidden = true;
}
_ => {
self.ex.warn(format!("{name}: video and image-sequence footage is not part of Lottie (layer exported as a null)"));
hidden = true;
}
}
}
LayerSource::Null => {}
LayerSource::Camera | LayerSource::Light { .. } => {
self.ex.warn(format!("{name}: cameras and lights are not exported (3D; exported as a hidden null to keep parenting)"));
hidden = true;
}
}
o.insert("ty".into(), json!(ty));
if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) {
o.insert("parent".into(), json!(p));
}
o.insert("sr".into(), json!(l.stretch / 100.0));
if l.stretch < 0.0 {
self.ex.warn(format!("{name}: reversed time stretch is not supported by most Lottie players"));
}
let ks = self.transform(l);
o.insert("ks".into(), ks);
o.insert("ao".into(), json!((l.auto_orient == effectcraft_project::AutoOrient::AlongPath) as u8));
o.insert("ip".into(), json!(self.frame(l.in_point)));
o.insert("op".into(), json!(self.frame(l.out_point)));
o.insert("st".into(), json!(self.frame(l.start_time)));
let bm = match crate::blend_to_lottie(l.blend_mode) {
Some(b) => b,
None => {
self.ex.warn(format!("{name}: blend mode {:?} has no Lottie equivalent (Normal used)", l.blend_mode));
0
}
};
o.insert("bm".into(), json!(bm));
let masks = self.masks(l);
if !masks.is_empty() {
o.insert("hasMask".into(), json!(true));
o.insert("masksProperties".into(), json!(masks));
}
let ef = self.effects(l);
if !ef.is_empty() {
o.insert("ef".into(), json!(ef));
}
if let Some(tm) = l.track_matte
&& let Some(mi) = comp.index_of(tm.layer)
{
o.insert("tt".into(), json!(matte_code(tm.kind)));
o.insert("tp".into(), json!(mi));
if mi != idx {
self.ex.warn(format!("{name}: track matte layer is not directly above (players without `tp` support use the layer above)"));
}
}
if is_matte {
o.insert("td".into(), json!(1));
if l.switches.video {
self.ex.warn(format!("{name}: visible track matte layer (Lottie always hides matte layers)"));
}
} else if hidden {
o.insert("hd".into(), json!(true));
}
if !l.markers.is_empty() {
self.ex.warn(format!("{name}: layer markers are not part of Lottie (composition markers are exported)"));
}
if l.props.sub(effectcraft_project::styles::GROUP).is_some_and(|g| g.groups().any(|s| s.enabled && s.match_id != effectcraft_project::styles::BLENDING))
{
self.ex.warn(format!("{name}: layer styles are not exported"));
}
if l.preserve_transparency {
self.ex.warn(format!("{name}: Preserve Underlying Transparency is not part of Lottie"));
}
Json::Object(o)
}
}
/// Export composition `comp` as a Lottie document. `read` loads footage files (still images are
/// embedded as base64 assets).
pub fn export_comp(project: &Project, comp: ItemId, opts: &ExportOptions, read: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<ExportResult, LottieError> {
let item = project.item(comp).ok_or_else(|| LottieError::Invalid(format!("no item {}", comp.0)))?;
let c = item.as_comp().ok_or_else(|| LottieError::Invalid(format!("`{}` is not a composition", item.name)))?;
let tb = Timebase { rate: c.frame_rate };
let mut doc = Doc {
project,
opts,
read,
ex: Ex { tb, include_expressions: opts.include_expressions, warnings: vec![] },
assets: vec![],
done: BTreeSet::new(),
fonts: vec![],
any_3d: false,
};
doc.done.insert(format!("comp_{}", comp.0));
let layers = doc.layers(c);
let markers: Vec<Json> = c.markers.iter().map(|m| json!({"tm": doc.frame(m.time), "cm": m.comment, "dr": doc.frame(m.duration)})).collect();
let mut out = json!({
"v": "5.12.0",
"fr": c.frame_rate.as_f64(),
"ip": 0,
"op": doc.frame(c.duration),
"w": c.width,
"h": c.height,
"nm": item.name,
"ddd": doc.any_3d as u8,
"assets": doc.assets,
"layers": layers,
"markers": markers,
});
if !doc.fonts.is_empty() {
out["fonts"] = json!({"list": doc.fonts});
}
Ok(ExportResult { json: out, warnings: doc.ex.warnings })
}
/// Package an export as a dotLottie archive (`manifest.json` + `animations/<id>.json`).
pub fn to_dotlottie(res: &ExportResult, id: &str) -> Vec<u8> {
let id: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
let id = if id.is_empty() { "animation".to_string() } else { id };
let manifest = json!({"version": "1", "generator": "EffectCraft", "author": "", "animations": [{"id": id, "speed": 1, "loop": true}]});
crate::zip::store(&[
("manifest.json".into(), serde_json::to_vec(&manifest).unwrap_or_default()),
(format!("animations/{id}.json"), serde_json::to_vec(&res.json).unwrap_or_default()),
])
}

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

@ -0,0 +1,463 @@
//! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]).
use std::collections::BTreeMap;
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
AlphaMode, AutoOrient, Comp, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MatteKind, Node, ParamUi, Project,
PropGroup, Property, Solid, TrackMatte,
};
use effectcraft_time::{FrameRate, Tick};
use serde_json::Value as Json;
use crate::anim::{self, Im, import_prop};
use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from};
use crate::{LottieError, Timebase, shapes, text};
/// An embedded image asset handed to the caller to store (the returned path becomes the
/// footage item's file).
#[derive(Clone, Debug)]
pub struct ImportedImage {
pub id: String,
pub name: String,
pub bytes: Vec<u8>,
/// File extension from the data URI (`png`, `jpg`, …).
pub ext: String,
}
#[derive(Clone, Debug)]
pub struct ImportResult {
/// The new top-level composition.
pub comp: ItemId,
/// Every item created (comps, solids, images, folder).
pub items: Vec<ItemId>,
pub warnings: Vec<String>,
}
fn f(j: &Json, k: &str) -> Option<f64> {
j.get(k).and_then(Json::as_f64)
}
fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> {
j.get(k).and_then(Json::as_str)
}
fn hex_color(sc: &str) -> [f32; 3] {
let h = sc.trim_start_matches('#');
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0;
[c(0), c(2), c(4)]
}
/// The Lottie JSON inside raw bytes (plain JSON, or a stored dotLottie archive).
fn document(bytes: &[u8]) -> Result<Json, LottieError> {
if bytes.starts_with(b"PK") {
let entries = crate::zip::read_stored(bytes);
let anim = entries
.iter()
.find(|(n, _)| n.starts_with("animations/") && n.ends_with(".json"))
.or_else(|| entries.iter().find(|(n, _)| n.ends_with(".json") && n != "manifest.json"))
.ok_or_else(|| LottieError::Parse("dotLottie archive without an uncompressed animation".into()))?;
return serde_json::from_slice(&anim.1).map_err(|e| LottieError::Parse(e.to_string()));
}
serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string()))
}
struct Ctx<'a> {
im: Im,
assets: BTreeMap<String, (ItemId, Option<(u32, u32)>)>,
fonts: Vec<(String, String, String)>,
store: &'a mut dyn FnMut(&ImportedImage) -> Option<String>,
base_dir: &'a str,
items: Vec<ItemId>,
folder: Option<ItemId>,
name: String,
}
impl Ctx<'_> {
fn folder(&mut self, project: &mut Project) -> ItemId {
if let Some(f) = self.folder {
return f;
}
let f = project.add_item(&format!("{} Assets", self.name), Label::None, None, ItemKind::Folder);
self.items.push(f);
self.folder = Some(f);
f
}
}
/// Import a Lottie document (JSON or a stored `.lottie` archive) into `project` as a new
/// composition. `store` saves embedded images and returns the file path to use;
/// `base_dir` resolves external image references.
pub fn import(
project: &mut Project,
bytes: &[u8],
fallback_name: &str,
base_dir: &str,
store: &mut dyn FnMut(&ImportedImage) -> Option<String>,
) -> Result<ImportResult, LottieError> {
let doc = document(bytes)?;
let (Some(w), Some(h)) = (f(&doc, "w"), f(&doc, "h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) };
let fr = f(&doc, "fr").filter(|v| *v > 0.0).unwrap_or(30.0);
let rate = FrameRate::from_f64(fr);
let tb = Timebase { rate };
let name = s(&doc, "nm").filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string();
let ip = f(&doc, "ip").unwrap_or(0.0);
let op = f(&doc, "op").unwrap_or(ip + fr).max(ip + 1.0);
let mut cx =
Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() };
if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) {
for fo in list {
let n = s(fo, "fName").unwrap_or("").to_string();
let fam = s(fo, "fFamily").unwrap_or(&n).to_string();
let st = s(fo, "fStyle").unwrap_or("Regular").to_string();
cx.fonts.push((n, fam, st));
}
}
let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
let duration = tb.tick(op);
// Pass 1: asset items (precomps empty for now, images stored).
let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default();
for a in &assets {
let Some(id) = s(a, "id") else { continue };
if a.get("layers").is_some() {
let folder = cx.folder(project);
let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h));
let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
let c = Comp::new(aw, ah, arate, duration);
let nm = s(a, "nm").unwrap_or(id).to_string();
let item = project.add_item(&nm, Label::Sandstone, Some(folder), ItemKind::Comp(Arc::new(c)));
cx.items.push(item);
cx.assets.insert(id.to_string(), (item, f(a, "w").zip(f(a, "h")).map(|(x, y)| (x as u32, y as u32))));
} else if let Some(p) = s(a, "p") {
let (aw, ah) = (f(a, "w").unwrap_or(0.0) as u32, f(a, "h").unwrap_or(0.0) as u32);
let nm = s(a, "nm").unwrap_or(id).to_string();
let path = if let Some((mime, data)) = crate::base64::data_uri(p) {
let ext = match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/svg+xml" => "svg",
_ => "png",
};
(cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() })
} else {
let u = s(a, "u").unwrap_or("");
let rel = format!("{u}{p}");
Some(if rel.starts_with('/') || cx.base_dir.is_empty() { rel } else { format!("{}/{rel}", cx.base_dir.trim_end_matches('/')) })
};
let Some(path) = path else {
cx.im.warn(format!("image asset `{id}` could not be stored"));
continue;
};
let folder = cx.folder(project);
let foot = Footage {
path,
kind: FootageKind::Still,
width: aw.max(1),
height: ah.max(1),
pixel_aspect: 1.0,
frame_rate: rate,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha: AlphaMode::Straight,
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
};
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
cx.items.push(item);
cx.assets.insert(id.to_string(), (item, Some((aw, ah))));
} else {
cx.im.warn(format!("asset `{id}` is not supported (skipped)"));
}
}
// Pass 2: precomp contents.
for a in &assets {
let (Some(id), Some(layers)) = (s(a, "id"), a.get("layers").and_then(Json::as_array)) else { continue };
let Some((item, _)) = cx.assets.get(id).copied() else { continue };
let Some(mut c) = project.comp(item).cloned() else { continue };
let end = layers.iter().filter_map(|l| f(l, "op")).fold(0.0f64, f64::max);
if end > 0.0 {
c.duration = tb.tick(end);
c.work_area = (Tick::ZERO, c.duration);
}
build_layers(&mut cx, project, &mut c, layers);
if let Some(it) = project.item_mut(item) {
it.kind = ItemKind::Comp(Arc::new(c));
}
}
// The main comp.
let mut comp = Comp::new(w, h, rate, duration);
if ip > 0.0 {
comp.work_area = (tb.tick(ip), duration);
}
if let Some(ms) = doc.get("markers").and_then(Json::as_array) {
for m in ms {
comp.markers.push(Marker {
time: tb.tick(f(m, "tm").unwrap_or(0.0)),
duration: tb.tick(f(m, "dr").unwrap_or(0.0)),
comment: s(m, "cm").unwrap_or("").to_string(),
..Default::default()
});
}
}
let layers = doc.get("layers").and_then(Json::as_array).cloned().unwrap_or_default();
build_layers(&mut cx, project, &mut comp, &layers);
let id = project.add_item(&name, Label::Sandstone, None, ItemKind::Comp(Arc::new(comp)));
cx.items.push(id);
project.fix_next_id();
Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
}
fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option<Value>| {
if let (Some(j), Some(p)) = (j, tr.get_mut(m)) {
import_prop(im, j, p, conv);
}
};
set(tr, "anchor", ks.get("a"), &|v| anim::to_vec3(v, 0.0));
set(tr, "scale", ks.get("s"), &|v| anim::to_vec3(v, 100.0));
set(tr, "rotation", ks.get("r").or(ks.get("rz")), &anim::to_scalar);
set(tr, "rotationX", ks.get("rx"), &anim::to_scalar);
set(tr, "rotationY", ks.get("ry"), &anim::to_scalar);
set(tr, "orientation", ks.get("or"), &|v| anim::to_vec3(v, 0.0));
set(tr, "opacity", ks.get("o"), &anim::to_scalar);
let Some(p) = ks.get("p") else { return };
if p.get("s").and_then(Json::as_bool) == Some(true) {
// Separated dimensions: X/Y/Z Position after Position.
let at = tr.children.iter().position(|c| c.match_id() == "position").map(|i| i + 1).unwrap_or(tr.children.len());
let base = tr.get("position").map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
for (d, (key, m, name)) in
[("x", "positionX", "X Position"), ("y", "positionY", "Y Position"), ("z", "positionZ", "Z Position")].iter().enumerate().rev()
{
let mut pr = Property::new(*next, m, name, Value::Scalar(base[d])).with_ui(ParamUi::Number);
*next += 1;
pr.three_d_only = d == 2;
if let Some(j) = p.get(*key) {
import_prop(im, j, &mut pr, &anim::to_scalar);
}
tr.children.insert(at, Node::Prop(pr));
}
} else {
set(tr, "position", Some(p), &|v| anim::to_vec3(v, 0.0));
}
}
fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
for (i, m) in list.iter().enumerate() {
let mode = mask_mode_from(s(m, "mode").unwrap_or("a"));
let name = s(m, "nm").map(str::to_string).unwrap_or(format!("Mask {}", i + 1));
let color = build::MASK_COLORS[i % build::MASK_COLORS.len()];
let mut mg = build::mask(ids, &name, ShapePath::default(), mode, color);
if let GroupKind::Mask { inverted, .. } = &mut mg.kind {
*inverted = m.get("inv").and_then(Json::as_bool).unwrap_or(false);
}
let mut set = |k: &str, mid: &str, conv: &dyn Fn(&Json) -> Option<Value>| {
if let (Some(j), Some(p)) = (m.get(k), mg.get_mut(mid)) {
import_prop(im, j, p, conv);
}
};
set("pt", "path", &shapes::to_path);
set("o", "opacity", &anim::to_scalar);
set("x", "expansion", &anim::to_scalar);
set("f", "feather", &anim::to_vec2);
g.children.push(Node::Group(mg));
}
}
fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [f64; 2]) {
for e in list {
let ty = e.get("ty").and_then(Json::as_u64).unwrap_or(0);
let params = e.get("ef").and_then(Json::as_array).cloned().unwrap_or_default();
let inner_ty = params.first().and_then(|p| p.get("ty")).and_then(Json::as_u64);
let map = s(e, "mn").and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty));
let nm = s(e, "nm").unwrap_or("Effect");
let Some(map) = map else {
im.warn(format!("effect `{nm}` (type {ty}) is not supported (skipped)"));
continue;
};
let Some(spec) = effectcraft_effects::find(map.id) else { continue };
let mut fg = effectcraft_effects::instantiate(spec, ids, nm, size);
if e.get("en").and_then(Json::as_u64) == Some(0) {
fg.enabled = false;
}
for (i, (m, pty, scale, _)) in fx_params(map).iter().enumerate() {
if m.is_empty() {
continue;
}
let Some(pj) = params.get(i).and_then(|p| p.get("v")) else { continue };
let Some(p) = fg.get_mut(m) else { continue };
let (pty, scale) = (*pty, *scale);
let conv = move |v: &Json| -> Option<Value> {
match pty {
2 => anim::to_color(v),
3 => anim::to_vec2(v),
4 => anim::to_bool(v),
7 => anim::to_enum1(v),
_ => anim::to_scalar(v).map(|x| Value::Scalar(x.as_f64() / scale)),
}
};
import_prop(im, pj, p, &conv);
}
g.children.push(Node::Group(fg));
}
}
fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> Option<(LayerSource, (u32, u32))> {
let ty = j.get("ty").and_then(Json::as_u64).unwrap_or(99);
let full = (comp.width, comp.height);
let asset = |cx: &Ctx| s(j, "refId").and_then(|r| cx.assets.get(r).copied());
Some(match ty {
0 => {
let Some((item, _)) = asset(cx) else {
cx.im.warn(format!("{}: missing precomp asset", s(j, "nm").unwrap_or("layer")));
return None;
};
let size = project.comp(item).map(|c| (c.width, c.height)).unwrap_or(full);
(LayerSource::Comp { item }, size)
}
1 => {
let sw = f(j, "sw").unwrap_or(comp.width as f64).round().max(1.0) as u32;
let sh = f(j, "sh").unwrap_or(comp.height as f64).round().max(1.0) as u32;
let color = hex_color(s(j, "sc").unwrap_or("#000000"));
let name = s(j, "nm").unwrap_or("Solid");
let folder = cx.folder(project);
let item = project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: sw, height: sh, pixel_aspect: 1.0 }));
cx.items.push(item);
(LayerSource::Solid { item }, (sw, sh))
}
2 => {
let Some((item, size)) = asset(cx) else {
cx.im.warn(format!("{}: missing image asset", s(j, "nm").unwrap_or("layer")));
return None;
};
(LayerSource::Footage { item }, size.unwrap_or(full))
}
3 => (LayerSource::Null, (100, 100)),
4 => (LayerSource::Shape, full),
5 => (LayerSource::Text, full),
13 => {
cx.im.warn(format!("{}: camera layers are not imported", s(j, "nm").unwrap_or("camera")));
return None;
}
other => {
cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", s(j, "nm").unwrap_or("layer")));
return None;
}
})
}
/// Build the layers of a comp (Lottie order: first = top = layer #1).
fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) {
let tb = cx.im.tb;
let mut by_ind: BTreeMap<i64, LayerId> = BTreeMap::new();
// (layer id, parent ind, matte: (kind, explicit tp ind), json position)
let mut pending: Vec<(LayerId, Option<i64>, Option<(MatteKind, Option<i64>)>, usize)> = vec![];
let mut pos_ind: Vec<Option<i64>> = vec![];
for (ji, j) in layers.iter().enumerate() {
let ind = j.get("ind").and_then(Json::as_i64);
pos_ind.push(ind);
let Some((source, size)) = layer_source(cx, project, comp, j) else { continue };
let name = s(j, "nm").unwrap_or(source.type_name()).to_string();
let mut l: Layer = build::layer(project, comp, &name, source.clone(), size, None);
if let Some(i) = ind {
by_ind.insert(i, l.id);
}
let ip = f(j, "ip").unwrap_or(0.0);
let op = f(j, "op").unwrap_or(tb.frame(comp.duration));
l.in_point = tb.tick(ip);
l.out_point = tb.tick(op.max(ip));
l.start_time = tb.tick(f(j, "st").unwrap_or(0.0));
l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0;
if j.get("ddd").and_then(Json::as_u64) == Some(1) {
l.switches.three_d = true;
}
if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
l.blend_mode = crate::blend_from_lottie(bm);
}
if j.get("ao").and_then(Json::as_u64) == Some(1) {
l.auto_orient = AutoOrient::AlongPath;
}
if j.get("hd").and_then(Json::as_bool) == Some(true) {
l.switches.video = false;
}
if j.get("td").and_then(Json::as_u64) == Some(1) {
l.switches.video = false;
}
let mut next = project.next_id;
if let (Some(ks), Some(tr)) = (j.get("ks"), l.props.sub_mut("transform")) {
transform(&cx.im, ks, tr, &mut next);
}
let mut ids = Ids(&mut next);
match &source {
LayerSource::Shape => {
let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default();
let groups = shapes::import_items(&mut cx.im, &mut ids, &items);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Node::Group));
}
}
LayerSource::Text => {
if let (Some(t), Some(tg)) = (j.get("t"), l.props.sub_mut("text")) {
text::import_text(&mut cx.im, &mut ids, t, tg, &cx.fonts);
}
}
LayerSource::Comp { .. } => {
if let Some(tm) = j.get("tm") {
let mut pr = ids.prop("timeRemap", "Time Remap", Value::Scalar(0.0)).with_ui(ParamUi::Number);
import_prop(&cx.im, tm, &mut pr, &anim::to_scalar);
l.props.children.insert(0, Node::Prop(pr));
}
}
_ => {}
}
if let Some(list) = j.get("masksProperties").and_then(Json::as_array)
&& let Some(g) = l.props.sub_mut("masks")
{
masks(&cx.im, &mut ids, list, g);
}
if let Some(list) = j.get("ef").and_then(Json::as_array)
&& let Some(g) = l.props.sub_mut("effects")
{
effects(&mut cx.im, &mut ids, list, g, [size.0 as f64, size.1 as f64]);
}
if j.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) {
cx.im.warn(format!("{name}: layer styles are not imported"));
}
project.next_id = next;
let parent = j.get("parent").and_then(Json::as_i64);
let tt = j.get("tt").and_then(Json::as_u64).unwrap_or(0);
let matte = match tt {
1 => Some(MatteKind::Alpha),
2 => Some(MatteKind::AlphaInverted),
3 => Some(MatteKind::Luma),
4 => Some(MatteKind::LumaInverted),
_ => None,
}
.map(|k| (k, j.get("tp").and_then(Json::as_i64)));
pending.push((l.id, parent, matte, ji));
comp.layers.push(l);
}
for (id, parent, matte, ji) in pending {
let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id);
let matte_id = matte.and_then(|(k, tp)| {
let target = match tp {
Some(tp) => by_ind.get(&tp).copied(),
// Legacy files: the matte is the layer directly above.
None => ji.checked_sub(1).and_then(|a| pos_ind.get(a).copied().flatten()).and_then(|i| by_ind.get(&i).copied()),
};
target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k })
});
if let Some(l) = comp.layer_mut(id) {
l.parent = parent_id;
l.track_matte = matte_id;
}
}
}

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,7 +71,15 @@ pub const MASK_COLORS: [[u8; 3]; 8] = [
pub fn mask(ids: &mut Ids, name: &str, path: ShapePath, mode: MaskMode, color: [u8; 3]) -> PropGroup {
let mut g = ids.group("mask", name);
g.kind = GroupKind::Mask { mode, inverted: false, color, locked: false };
g.kind = GroupKind::Mask {
mode,
inverted: false,
color,
locked: false,
motion_blur: Default::default(),
feather_falloff: Default::default(),
roto_bezier: false,
};
g.with(ids.prop("path", "Mask Path", Value::Path(path)).with_ui(ParamUi::Path))
.with(ids.prop("feather", "Mask Feather", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Pixels))
.with(ids.prop("opacity", "Mask Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))

View File

@ -15,7 +15,7 @@ use std::sync::Arc;
use effectcraft_color::{BlendMode, Label};
use effectcraft_time::{FrameRate, Tick};
pub use props::{Expression, GroupKind, MaskMode, Node, ParamUi, PropGroup, Property, Uid, parse_path};
pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path};
use serde::{Deserialize, Serialize};
pub use effectcraft_keyframe as keyframe;

View File

@ -165,15 +165,40 @@ pub enum GroupKind {
inverted: bool,
color: [u8; 3],
locked: bool,
/// Layer ▸ Mask ▸ Motion Blur.
#[serde(default)]
motion_blur: MaskMotionBlur,
/// Layer ▸ Mask ▸ Feather Falloff.
#[serde(default)]
feather_falloff: FeatherFalloff,
/// Layer ▸ Mask and Shape Path ▸ RotoBezier: tangents follow the vertices automatically.
#[serde(default)]
roto_bezier: bool,
},
Effect {
/// Effect spec id, e.g. `ec.blur.gaussian`.
effect: String,
},
/// A motion tracker (Motion Trackers ▸ Tracker n) with its Tracker panel settings.
Tracker {
settings: Box<crate::tracking::TrackerSettings>,
},
Tracker { settings: Box<crate::tracking::TrackerSettings> },
}
/// Mask motion blur (sub-frame samples of an animated mask path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MaskMotionBlur {
/// Blur when the layer's Motion Blur switch is on.
#[default]
SameAsLayer,
On,
Off,
}
/// How a mask's feather ramps: Smooth (Gaussian) or Linear.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FeatherFalloff {
#[default]
Smooth,
Linear,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]

View File

@ -401,6 +401,37 @@ pub struct RenderQueueItem {
/// Resolved path of the last render (first file for sequences).
#[serde(default)]
pub last_output: Option<String>,
/// Further Output Modules (Composition ▸ Add Output Module): the same frames are encoded
/// once per module.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_outputs: Vec<OutputModule>,
/// Post-Render Action of the first output module.
#[serde(default, skip_serializing_if = "PostRenderAction::is_none")]
pub post_render: PostRenderAction,
}
/// What happens after an item renders (Output Module ▸ Post-Render Action).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PostRenderAction {
#[default]
None,
/// Import the rendered file into the project.
Import,
/// Import it and replace every use of the rendered composition (Composition ▸ Pre-render).
ImportAndReplace,
}
impl PostRenderAction {
pub fn is_none(&self) -> bool {
*self == PostRenderAction::None
}
pub fn label(self) -> &'static str {
match self {
PostRenderAction::None => "None",
PostRenderAction::Import => "Import",
PostRenderAction::ImportAndReplace => "Import & Replace Usage",
}
}
}
impl RenderQueueItem {
@ -415,8 +446,14 @@ impl RenderQueueItem {
started: None,
render_time: None,
last_output: None,
extra_outputs: vec![],
post_render: PostRenderAction::None,
}
}
/// Every output module, the first one first.
pub fn output_modules(&self) -> impl Iterator<Item = &OutputModule> {
std::iter::once(&self.output).chain(self.extra_outputs.iter())
}
/// Will be rendered by the next Render.
pub fn is_queued(&self) -> bool {
self.render && matches!(self.status, RenderStatus::Queued)

View File

@ -299,6 +299,12 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
layer.id.hash(&mut h);
hash_debug(&mut h, &layer.source);
hash_debug(&mut h, &layer.switches);
// Mask motion blur samples the comp's shutter.
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
ctx.comp.enable_motion_blur.hash(&mut h);
ctx.comp.shutter_angle.to_bits().hash(&mut h);
ctx.comp.shutter_phase.to_bits().hash(&mut h);
}
for c in &layer.props.children {
// Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]).
if let Node::Group(g) = c

View File

@ -4,14 +4,52 @@ use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::Value;
use effectcraft_path::{FillRule, StrokeStyle};
use effectcraft_project::{GroupKind, Layer, MaskMode};
use effectcraft_raster::{Mask, gaussian_blur};
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur};
use effectcraft_raster::{Mask, box_blur, gaussian_blur};
use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Coverage of one mask in buffer pixels (before mode/opacity/invert).
/// Sub-frame samples for mask motion blur (animated mask paths only).
const MASK_BLUR_SAMPLES: usize = 8;
/// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur
/// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated
/// path is averaged over the comp's shutter.
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
let mb = match g.kind {
GroupKind::Mask { motion_blur, .. } => match motion_blur {
MaskMotionBlur::On => true,
MaskMotionBlur::Off => false,
MaskMotionBlur::SameAsLayer => layer.switches.motion_blur,
},
_ => false,
};
let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression());
if mb && animated && ctx.comp.enable_motion_blur {
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let mut acc: Option<Mask> = None;
let k = 1.0 / MASK_BLUR_SAMPLES as f32;
for i in 0..MASK_BLUR_SAMPLES {
let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64;
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
let Some(c) = coverage_at(&sub, layer, g, buf) else { continue };
match &mut acc {
None => {
let mut c = c;
c.data.iter_mut().for_each(|v| *v *= k);
acc = Some(c);
}
Some(a) => a.data.iter_mut().zip(&c.data).for_each(|(a, c)| *a += c * k),
}
}
return acc;
}
coverage_at(ctx, layer, g, buf)
}
fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None };
let path = effectcraft_path::to_kurbo(&sp);
let (w, h) = (buf.img.width, buf.img.height);
@ -33,7 +71,14 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
if feather[0] > 0.0 || feather[1] > 0.0 {
let img = cov.to_image();
let b = gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false);
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
let b = if linear {
// One box pass: a straight ramp `feather` pixels wide across the edge.
let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 });
box_blur(&img, r(feather[0]), r(feather[1]), 1, false)
} else {
gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false)
};
cov = Mask::from_alpha(&b);
}
Some(cov)

View File

@ -223,9 +223,9 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
}
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
for w in crate::dock::WORKSPACES {
if ui.selectable_label(app.ui.workspace == w, w).clicked() {
app.set_workspace(w);
for w in app.workspace_names() {
if ui.selectable_label(app.ui.workspace == w, &w).clicked() {
app.set_workspace(&w);
ui.close();
}
}

View File

@ -242,7 +242,14 @@ impl EffectcraftApp {
pub fn set_workspace(&mut self, name: &str) {
self.ui.workspace = name.to_string();
self.ui.dock = dock::workspace(name);
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
}
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
pub fn workspace_names(&self) -> Vec<String> {
let mut v: Vec<String> = dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
v.extend(self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned());
v
}
pub fn show_panel(&mut self, p: PanelKind) {

View File

@ -452,11 +452,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
"window.workspace" => {
let want = p.get("name").and_then(Value::as_str).unwrap_or("Default").to_ascii_lowercase().replace(' ', "");
let name = crate::dock::WORKSPACES
.iter()
let name = app
.workspace_names()
.into_iter()
.find(|w| w.to_ascii_lowercase().replace(' ', "") == want)
.ok_or_else(|| format!("unknown workspace `{want}`"))?;
app.set_workspace(name);
app.set_workspace(&name);
json!({"workspace": name})
}
"window.resetWorkspace" => {
@ -464,6 +465,80 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.set_workspace(&name);
Value::Null
}
"window.saveWorkspace" => {
let name = app.ui.workspace.clone();
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
json!({"workspace": name})
}
"window.saveWorkspaceAs" => {
let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) else {
crate::panels::dialogs::form(
app,
"New Workspace",
"window.saveWorkspaceAs",
json!({}),
vec![crate::panels::dialogs::Field::text("name", "Name", "Untitled Workspace")],
);
return Ok(Value::Null);
};
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.workspace = name.clone();
json!({"workspace": name})
}
"window.editWorkspaces" => {
let Some(name) = p.get("name").and_then(Value::as_str).map(str::to_string) else {
let cur = app.ui.workspace.clone();
crate::panels::dialogs::form(
app,
"Edit Workspaces",
"window.editWorkspaces",
json!({}),
vec![
crate::panels::dialogs::Field::text("name", "Workspace", &cur),
crate::panels::dialogs::Field::text("rename", "Rename to", &cur),
crate::panels::dialogs::Field::bool("delete", "Delete", false),
],
);
return Ok(Value::Null);
};
let builtin = crate::dock::WORKSPACES.contains(&name.as_str());
if p.get("delete").and_then(Value::as_bool) == Some(true) {
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
return Err(format!("no workspace `{name}`"));
}
if app.ui.workspace == name {
app.set_workspace("Default");
}
return Ok(json!({"deleted": name}));
}
if let Some(new) = p.get("rename").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty() && *n != name) {
if builtin {
return Err(format!("`{name}` is a built-in workspace: save it as a new workspace instead"));
}
let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?;
app.ui.saved_workspaces.insert(new.to_string(), dock);
if app.ui.workspace == name {
app.ui.workspace = new.to_string();
}
return Ok(json!({"renamed": new}));
}
json!({"workspaces": app.workspace_names()})
}
"view.lookAt" => {
let r: Vec<f64> = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
if r.len() == 4 {
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::new("viewer-map")));
if let Some(m) = map {
let (rw, rh) = (((r[2] - r[0]) as f32).max(1.0), ((r[3] - r[1]) as f32).max(1.0));
let zoom = ((m.area.width() - 40.0) / rw).min((m.area.height() - 40.0) / rh).clamp(0.015, 32.0);
let rc = [(r[0] + r[2]) as f32 / 2.0, (r[1] + r[3]) as f32 / 2.0];
let v = &mut app.ui.viewer;
v.zoom = Some(zoom);
v.pan = [m.comp[0] * zoom / 2.0 - rc[0] * zoom, m.comp[1] * zoom / 2.0 - rc[1] * zoom];
}
}
Value::Null
}
"comp.flowchart" | "comp.miniFlowchart" => {
app.show_panel(PanelKind::Flowchart);
Value::Null
@ -517,6 +592,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
"view.importGuides" => ("path", Ask::Open(&["json"])),
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
"file.replaceFootage" => ("path", Ask::Import),
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),

View File

@ -137,6 +137,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
};
(title.into(), fields)
}
"path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
"Free Transform Points".into(),
vec![
Field::num("scale[0]", "Scale X (%)", 100.0),
Field::num("scale[1]", "Scale Y (%)", 100.0),
Field::num("rotation", "Rotation (degrees)", 0.0),
Field::num("offset[0]", "Move X", 0.0),
Field::num("offset[1]", "Move Y", 0.0),
],
),
"layer.mask.set" if !has(p, &["value"]) => {
let field = p.get("field").and_then(Value::as_str).unwrap_or("feather");
let first = layer.and_then(|l| l.masks()).and_then(|m| m.groups().next()).and_then(|g| g.get(field)).map(|pr| pr.value_at(lt).as_f64());

View File

@ -213,6 +213,82 @@ fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
}
}
/// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle.
fn aux_views(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
comp: &effectcraft_engine::project::Comp,
cid: effectcraft_engine::project::ItemId,
full: Rect,
bg: Color32,
) -> Rect {
let n = app.session.state.view_layout;
if n < 2 {
return full;
}
let gap = 2.0;
let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 {
let mid = full.center().x;
(Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), View3D::Top)])
} else {
let c = full.center();
let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
(
q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y),
vec![
(q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), View3D::Top),
(q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front),
(q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right),
],
)
};
let ctx = ui.ctx().clone();
let p = ui.painter().clone();
p.rect_filled(full, 0.0, Color32::from_black_alpha(200));
let t = app.session.time();
let (cw, ch) = (comp.width as f32, comp.height as f32);
let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default();
let share = app.session.state.share_view_options;
for (i, (r, v)) in aux.into_iter().enumerate() {
p.rect_filled(r, 0.0, bg);
let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01);
let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit));
let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64;
let cam = views.cam(v, comp.width as f64, comp.height as f64).state();
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h);
h.finish()
};
let id = egui::Id::new(("viewer-aux", i));
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
let tex = match cached {
Some((k, tex)) if k == key => tex,
_ => {
let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() };
let img = app.session.render(cid, t, opts);
let tex = ctx.load_texture(format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
let b = comp.background;
p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8));
p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
if share && app.ui.viewer.safe_margins {
for k in [0.9, 0.8] {
p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle);
}
}
let label = if comp.has_3d() { v.label() } else { "Active Camera" };
p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200));
app.auto.add(&format!("viewer.view.{}", v.id()), r, label);
}
main
}
pub(crate) fn checker(p: &egui::Painter, r: Rect) {
let s = 10.0;
p.rect_filled(r, 0.0, Color32::from_gray(0xcc));
@ -274,8 +350,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Bottom control bar.
let bar_h = 30.0;
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
let area = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
// keeps the overlays and the interaction.
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
p.rect_filled(area, 0.0, pasteboard);
let (cw, ch) = (comp.width as f32, comp.height as f32);
@ -443,7 +522,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
{
let (m, _) = l2c(&ectx, l);
for g in masks.groups() {
let GroupKind::Mask { color, .. } = g.kind else { continue };
let GroupKind::Mask { color, locked, .. } = g.kind else { continue };
if locked && app.session.state.hide_locked_masks {
continue;
}
let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue };
let Some(sp) = path.as_path() else { continue };
let k = effectcraft_engine::render::kurbo_path(sp);

View File

@ -385,6 +385,9 @@ pub struct UiState {
pub slot_tools: Vec<Tool>,
pub workspace: String,
pub dock: DockNode,
/// Saved workspace layouts (Save Changes to this Workspace / Save as New Workspace).
#[serde(default)]
pub saved_workspaces: std::collections::BTreeMap<String, DockNode>,
pub focused: PanelKind,
pub viewer: ViewerState,
pub timeline: TimelineState,
@ -462,6 +465,7 @@ impl Default for UiState {
slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(),
workspace: "Default".into(),
dock: crate::dock::workspace("Default"),
saved_workspaces: Default::default(),
focused: PanelKind::Composition,
viewer: ViewerState::default(),
timeline: TimelineState::default(),

View File

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

View File

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