diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index fb8f22e8..10635b69 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -17,6 +17,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
+| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 |
## Screenshots
diff --git a/Cargo.lock b/Cargo.lock
index 2559674c..68e5616f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 38cd14e8..8212d74a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/README.md b/README.md
index 24006fbd..d4ccd4ac 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,11 @@ A Render Queue like After Effects', with Render Settings and Output Modules: **H
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
FilmCraft's pure-Rust code; there is no FFmpeg inside.
+**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,
+solid, image, text and null layers, eased and spatial keyframes, masks, track mattes, blend
+modes, time remapping, optionally expressions) as `.json` or `.lottie`, and lists anything Lottie
+cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
+
@@ -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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 106b464d..3e79f52e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -17,7 +17,7 @@ once.
| M8 | Expressions with the After Effects object model | Done |
| M9 | Text and effects: text animators, layer styles, 259 effects incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
-| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
+| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 9d77eea4..132bdcac 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -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 }
diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs
index 2a02356f..6f9f6098 100644
--- a/crates/engine/src/commands/frontend.rs
+++ b/crates/engine/src/commands/frontend.rs
@@ -69,6 +69,9 @@ pub fn specs() -> Vec {
"{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),
diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs
index f183e7a0..b441c366 100644
--- a/crates/engine/src/commands/layer_menu.rs
+++ b/crates/engine/src/commands/layer_menu.rs
@@ -256,7 +256,7 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result {
/// 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![];
diff --git a/crates/engine/src/commands/lottie.rs b/crates/engine/src/commands/lottie.rs
new file mode 100644
index 00000000..03a99b99
--- /dev/null
+++ b/crates/engine/src/commands/lottie.rs
@@ -0,0 +1,67 @@
+//! File ▸ Export ▸ Lottie JSON… and File ▸ Import ▸ Lottie… (`effectcraft-lottie`).
+
+use effectcraft_lottie::{ExportOptions, ImportedImage};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, str_p};
+use crate::{EngineError, Result, Session, cmd};
+
+fn export_lottie(s: &mut Session, p: &Value) -> Result {
+ let path = str_p(p, "path").ok_or_else(|| bad("file.exportLottie", "missing `path`"))?.to_string();
+ let cid = comp_id(s, p)?;
+ let opts = ExportOptions { include_expressions: b_p(p, "includeExpressions").unwrap_or(false), text_as_shapes: b_p(p, "textAsShapes").unwrap_or(false) };
+ let services = s.services.clone();
+ let read = move |f: &str| services.read_file(f).ok();
+ let res = effectcraft_lottie::export_comp(&s.project, cid, &opts, &read).map_err(|e| EngineError::Other(e.to_string()))?;
+ let dot = path.to_ascii_lowercase().ends_with(".lottie");
+ let bytes = if dot {
+ let stem = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ effectcraft_lottie::to_dotlottie(&res, &stem)
+ } else {
+ res.to_string_compact().into_bytes()
+ };
+ s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
+ let n = res.warnings.len();
+ s.toast(if n == 0 { format!("Exported Lottie to {path}") } else { format!("Exported Lottie to {path} ({n} warning{})", if n == 1 { "" } else { "s" }) });
+ Ok(json!({"path": path, "bytes": bytes.len(), "warnings": res.warnings}))
+}
+
+fn import_lottie(s: &mut Session, p: &Value) -> Result {
+ let path = str_p(p, "path").ok_or_else(|| bad("file.importLottie", "missing `path`"))?.to_string();
+ let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
+ let fp = std::path::Path::new(&path);
+ let dir = fp.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
+ let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Lottie".into());
+ let services = s.services.clone();
+ // Embedded images are written next to the Lottie file (`_.`).
+ let mut store = |img: &ImportedImage| -> Option {
+ let safe: String = img.id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
+ let file = std::path::Path::new(&dir).join(format!("{stem}_{safe}.{}", img.ext)).to_string_lossy().to_string();
+ services.write_file(&file, &img.bytes).ok().map(|_| file)
+ };
+ let res = s.edit("Import Lottie", None, |proj, st| {
+ let r = effectcraft_lottie::import(proj, &bytes, &stem, &dir, &mut store).map_err(|e| EngineError::Other(e.to_string()))?;
+ st.project_selection = vec![r.comp];
+ Ok(r)
+ })?;
+ s.open_comp(res.comp);
+ if !res.warnings.is_empty() {
+ s.toast(format!("Imported {path} ({} warnings)", res.warnings.len()));
+ }
+ Ok(json!({"comp": res.comp.0, "items": res.items.iter().map(|i| i.0).collect::>(), "warnings": res.warnings}))
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "file.exportLottie",
+ "Lottie JSON...",
+ ["File", "Export"],
+ None,
+ "{comp?, path (.json or .lottie), includeExpressions?: bool, textAsShapes?: bool}",
+ has_comp,
+ export_lottie
+ ),
+ cmd!("file.importLottie", "Lottie...", ["File", "Import"], None, "{path (.json or .lottie)}", always, import_lottie),
+ ]
+}
diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs
index 617346c1..8c671ecd 100644
--- a/crates/engine/src/commands/mask.rs
+++ b/crates/engine/src/commands/mask.rs
@@ -44,9 +44,13 @@ fn edit_path(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(())
diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs
index 0795bba3..d0fcfd21 100644
--- a/crates/engine/src/commands/mod.rs
+++ b/crates/engine/src/commands/mod.rs
@@ -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());
diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs
new file mode 100644
index 00000000..0c0436ab
--- /dev/null
+++ b/crates/engine/src/commands/paths.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = 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 = {
+ let sel: Vec =
+ 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 {
+ 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 = 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 = 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 {
+ 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 = 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 {
+ 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),
+ ]
+}
diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs
index b2d4f9e8..d22384dd 100644
--- a/crates/engine/src/commands/render_queue.rs
+++ b/crates/engine/src/commands/render_queue.rs
@@ -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 {
Ok(any)
}
+/// `module` (1-based; 1 = the first output module).
+fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result {
+ 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 = 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 {
fn set_output_module(s: &mut Session, p: &Value) -> Result {
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 {
fn set_output(s: &mut Session, p: &Value) -> Result {
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 {
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::>()}))
}
+/// `item?|index?`, else the last item (the queue's newest entry).
+fn item_or_last(s: &Session, p: &Value, cmd: &str) -> Result {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
vec![
cmd!(
@@ -413,7 +528,7 @@ pub fn specs() -> Vec {
"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 {
"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),
diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs
index 76cbd18c..c04f1f98 100644
--- a/crates/engine/src/commands/stubs.rs
+++ b/crates/engine/src/commands/stubs.rs
@@ -21,9 +21,6 @@ pub fn specs() -> Vec {
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 {
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 {
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 {
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 {
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}"),
]
}
diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs
index de733128..aea32105 100644
--- a/crates/engine/src/commands/three_d.rs
+++ b/crates/engine/src/commands/three_d.rs
@@ -529,6 +529,122 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result {
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 {
+ 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 {
+ 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 = 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 {
+ 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 {
vec![
cmd!(
@@ -596,6 +712,11 @@ pub fn specs() -> Vec {
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!(
diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs
index 83b23cd3..b3d86565 100644
--- a/crates/engine/src/commands/view.rs
+++ b/crates/engine/src/commands/view.rs
@@ -75,12 +75,28 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result {
Ok(json!(s.state.region_of_interest))
}
+fn view_layout(s: &mut Session, p: &Value) -> Result {
+ 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 {
+ 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 {
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),
]
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index f1af0111..8b5c5f43 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -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;
diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs
index 25baa140..606ad18c 100644
--- a/crates/engine/src/menus.rs
+++ b/crates/engine/src/menus.rs
@@ -162,6 +162,21 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option {
})
}
"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
---
diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs
index 61bc0fab..95c90824 100644
--- a/crates/engine/src/render_queue.rs
+++ b/crates/engine/src/render_queue.rs
@@ -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,
expr: Option>,
exporter: Arc,
- items: Vec<(RenderQueueItem, String)>,
+ /// Per queue item: one (item with that output module, resolved path) per output module.
+ items: Vec>,
}
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 = 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 = items.iter().map(|(i, _)| i.id).collect();
+ let ids: Vec = 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