Files
effectcraft/crates/lottie/src/export.rs
T
Brandon ThomasandClaude Opus 5.5 83c7e14b94 M7.4: model import
New crate effectcraft-model (L2): glTF 2.0 (.gltf/.glb, data URIs, external buffers and
images, PBR metallic-roughness with base/MR/normal/occlusion/emissive textures, node
hierarchy, skins, step/linear/cubic animations) and OBJ/MTL import, parametric primitives,
extruded/bevelled outline meshes and polygon triangulation with holes.

Project: FootageKind::Model, LayerSource::Model/Primitive, Layer::environment, Geometry and
Material Options builders, Environment light Source. Media probes and loads models
(FootageSource::model). Engine: layer.newModel, layer.new3dPrimitive/newPrimitive,
comp.renderer, layer.environment, material.set/reset/duplicateAssign/revealSource (stubs
replaced); File > Import and layer.addItem pick up models. Hand-written fixtures with
attribution sidecars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:04:47 -04:00

518 lines
21 KiB
Rust

//! 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;
}
LayerSource::Model { .. } | LayerSource::Primitive { .. } => {
self.ex.warn(format!("{name}: 3D model layers are not part of Lottie (layer exported as a hidden null)"));
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()),
])
}