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