M13.7: Save Frame As Photoshop Layers / ProEXR, Watch Folder, Vanishing Point (disabled), Create Nulls From Paths, VR Comp Editor, Help tutorials

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 07:55:04 -04:00
co-authored by Claude Opus 5.5
parent 1772250fc4
commit 0698b23cd7
22 changed files with 1347 additions and 9 deletions
+1
View File
@@ -34,3 +34,4 @@ log = { workspace = true }
rayon = { workspace = true }
image = { workspace = true }
web-time = { workspace = true }
exr = { workspace = true }
+4 -4
View File
@@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result<Value> {
/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the
/// given world directions: searched over quarter turns, which is all the cube faces need.
fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
pub(crate) fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
let mut best = ([0.0; 3], f64::MAX);
for a in 0..4 {
for b in 0..4 {
@@ -416,7 +416,7 @@ fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up
/// in the VR convention (X right, Y up, Z front).
const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
pub(crate) const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
@@ -425,10 +425,10 @@ const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
];
/// (column, row) of each face in the 3:2 cube map.
const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
pub(crate) const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
/// VR world direction → After Effects world (Y down).
fn ae(v: [f64; 3]) -> Vec3 {
pub(crate) fn ae(v: [f64; 3]) -> Vec3 {
vec3(v[0], -v[1], v[2])
}
+240
View File
@@ -0,0 +1,240 @@
//! Composition ▸ Save Frame As ▸ Photoshop Layers… and ProEXR…: the current frame with every
//! visible layer kept apart.
//!
//! - **Photoshop Layers** writes a layered PSD (crates/psd's writer): one pixel layer per comp
//! layer, each rendered in isolation (solo, Normal mode, 100% opacity) and cropped to its
//! pixels, carrying the layer's blend mode and opacity, plus the flattened frame as the merged
//! image. Photoshop (and EffectCraft's PSD import) rebuild the frame from the layers.
//! - **ProEXR** writes one multi-layer OpenEXR (half-float-free, 32-bit float, linear light,
//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `<layer name>.R/G/B/A` channel
//! set per layer (the layer-prefixed channel convention multi-layer EXR readers use).
use effectcraft_color::BlendMode;
use effectcraft_project::{ItemId, LayerId, Project};
use effectcraft_raster::Image;
use effectcraft_render::{Backend, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
/// One isolated layer of a frame: name, blend mode, opacity (0..1), premultiplied pixels.
struct Isolated {
name: String,
blend: BlendMode,
opacity: f64,
image: Image,
}
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
let opts = RenderOpts {
scale,
backend: Backend::Auto,
nested_switches: s.prefs.general.switches_affect_nested_comps,
draft_shadows: s.prefs.three_d.realtime_shadows,
..Default::default()
};
let mut r = Renderer::new(project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
r.accel = s.accel.as_deref();
r.comp_frame(comp, t)
}
/// The comp frame and each visible layer rendered alone (top layer first).
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<Isolated>)> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let full = render_with(s, &s.project, cid, t, scale);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video());
let ids: Vec<LayerId> = comp
.layers
.iter()
.filter(|l| l.has_video() && l.is_active_at(t) && !l.switches.adjustment && !l.switches.guide && !l.environment && (!any_solo || l.switches.solo))
.map(|l| l.id)
.collect();
let mut out = vec![];
for id in ids {
let mut p: Project = (*s.project).clone();
let Some(c) = p.comp_mut(cid) else { continue };
let mut info = None;
for l in c.layers.iter_mut() {
l.switches.solo = l.id == id;
if l.id == id {
let lt = l.layer_time(t);
let opacity = l.props.prop("transform/opacity").map(|o| o.value_at(lt).as_f64() / 100.0).unwrap_or(1.0);
info = Some((l.name.clone(), l.blend_mode, opacity.clamp(0.0, 1.0)));
l.blend_mode = BlendMode::Normal;
if let Some(o) = l.props.prop_mut("transform/opacity") {
o.keys.clear();
o.expr = None;
o.value = effectcraft_keyframe::Value::Scalar(100.0);
}
}
}
let Some((name, blend, opacity)) = info else { continue };
let image = render_with(s, &p, cid, t, scale);
out.push(Isolated { name, blend, opacity, image });
}
Ok((full, out))
}
fn straight(p: [f32; 4]) -> [f32; 4] {
if p[3] <= 0.0 { [0.0; 4] } else { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3].min(1.0)] }
}
/// The Photoshop key of a blend mode (Normal for modes Photoshop lacks).
pub fn psd_blend_key(m: BlendMode) -> [u8; 4] {
const KEYS: [&[u8; 4]; 26] = [
b"diss", b"dark", b"mul ", b"idiv", b"lbrn", b"dkCl", b"lite", b"scrn", b"div ", b"lddg", b"lgCl", b"over", b"sLit", b"hLit", b"vLit", b"lLit",
b"pLit", b"hMix", b"diff", b"smud", b"fsub", b"fdiv", b"hue ", b"sat ", b"colr", b"lum ",
];
KEYS.iter().find(|k| crate::psd_import::blend_mode(std::str::from_utf8(&k[..]).unwrap_or("")) == m).map(|k| **k).unwrap_or(*b"norm")
}
/// The bounding box of non-transparent pixels (`None` when empty).
fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> {
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
for y in 0..img.height {
for x in 0..img.width {
if img.data[(y * img.width + x) as usize][3] > 0.5 / 255.0 {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x + 1);
y1 = y1.max(y + 1);
}
}
}
(x1 > x0).then_some((x0, y0, x1, y1))
}
/// The layered PSD bytes of a frame.
pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, usize, u32, u32)> {
use effectcraft_psd::Rect;
use effectcraft_psd::write::{WDoc, WLayer, write};
let (full, layers) = isolate(s, cid, t, scale)?;
let mut doc = WDoc::new(full.width, full.height);
let n = layers.len();
// File order is bottom first.
for l in layers.into_iter().rev() {
let mut w = match alpha_bounds(&l.image) {
Some((x0, y0, x1, y1)) => {
let mut px = Vec::with_capacity(((x1 - x0) * (y1 - y0)) as usize);
for y in y0..y1 {
for x in x0..x1 {
px.push(straight(l.image.data[(y * l.image.width + x) as usize]));
}
}
WLayer::pixels(&l.name, Rect::new(x0 as i32, y0 as i32, (x1 - x0) as i32, (y1 - y0) as i32), px)
}
None => WLayer::pixels(&l.name, Rect::default(), vec![]),
};
w.blend = psd_blend_key(l.blend);
w.opacity = (l.opacity * 255.0).round() as u8;
doc.layers.push(w);
}
doc.composite = Some(full.data.iter().map(|p| straight(*p)).collect());
Ok((write(&doc), n, full.width, full.height))
}
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// A unique EXR layer name (no dots: they separate the layer from the channel).
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect();
let base = if base.trim().is_empty() { "Layer".to_string() } else { base };
let mut n = base.clone();
let mut k = 2;
while used.contains(&n) {
n = format!("{base} {k}");
k += 1;
}
used.push(n.clone());
n
}
/// The multi-layer EXR bytes of a frame and the channel names.
pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, Vec<String>, u32, u32)> {
use exr::prelude::*;
let (full, layers) = isolate(s, cid, t, scale)?;
let (w, h) = (full.width as usize, full.height as usize);
let planes = |img: &effectcraft_raster::Image| -> [Vec<f32>; 4] {
let mut out: [Vec<f32>; 4] = Default::default();
for p in &img.data {
let sp = straight(*p);
let a = sp[3];
for c in 0..3 {
out[c].push(srgb_to_linear(sp[c].clamp(0.0, 1.0)) * a);
}
out[3].push(a);
}
out
};
let mut channels = vec![];
let mut names = vec![];
let mut push = |prefix: &str, img: &effectcraft_raster::Image| {
for (c, data) in ["R", "G", "B", "A"].iter().zip(planes(img)) {
let n = if prefix.is_empty() { c.to_string() } else { format!("{prefix}.{c}") };
names.push(n.clone());
channels.push(AnyChannel::new(n.as_str(), FlatSamples::F32(data)));
}
};
push("", &full);
let mut used = vec![];
for l in &layers {
let n = exr_name(&l.name, &mut used);
push(&n, &l.image);
}
let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into()));
let mut buf = std::io::Cursor::new(Vec::new());
Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?;
Ok((buf.into_inner(), names, full.width, full.height))
}
fn frame_args(s: &Session, p: &Value, cmd: &str, ext: &str) -> Result<(String, ItemId, Tick, f64)> {
let path = str_p(p, "path").ok_or_else(|| bad(cmd, format!("missing `path` (.{ext})")))?.to_string();
let cid = comp_id(s, p)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
Ok((path, cid, t, scale))
}
fn save_psd(s: &mut Session, p: &Value) -> Result<Value> {
let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsPsd", "psd")?;
let (bytes, n, w, h) = layered_psd(s, cid, t, scale)?;
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved {n} layers to {path}"));
Ok(json!({"path": path, "layers": n, "width": w, "height": h, "time": t.seconds()}))
}
fn save_exr(s: &mut Session, p: &Value) -> Result<Value> {
let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsExr", "exr")?;
let (bytes, channels, w, h) = layered_exr(s, cid, t, scale)?;
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved a multi-layer EXR to {path}"));
Ok(json!({"path": path, "channels": channels, "width": w, "height": h, "time": t.seconds()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"comp.saveFrameAsPsd",
"Photoshop Layers...",
["Composition", "Save Frame As"],
None,
"{path (.psd), comp?, time?, scale?} → a layered PSD: one layer per visible comp layer (blend mode, opacity) + the merged frame",
has_comp,
save_psd
),
cmd!(
"comp.saveFrameAsExr",
"ProEXR...",
["Composition", "Save Frame As"],
None,
"{path (.exr), comp?, time?, scale?} → a multi-layer OpenEXR: composite R,G,B,A + `<layer>.R/G/B/A` per layer (linear, premultiplied)",
has_comp,
save_exr
),
]
}
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show Panel",
[],
None,
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}",
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}",
always
),
fe!(
+6
View File
@@ -35,6 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend(
s,
"help.inAppTutorials",
p
)),
cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
cmd!(
"help.sibling",
+12 -2
View File
@@ -5,7 +5,7 @@ mod align_data;
mod anim;
pub mod anim_tools;
mod animation;
mod app_more;
pub(crate) mod app_more;
mod autotrace;
mod camera_cmds;
mod comp;
@@ -20,6 +20,7 @@ mod file;
pub(crate) mod file_more;
mod focus;
pub mod footage_panel;
pub(crate) mod frame_export;
mod frontend;
mod help;
mod key_labels;
@@ -28,7 +29,7 @@ mod keys_more;
mod layer;
mod layer_menu;
mod layer_time;
mod link;
pub(crate) mod link;
mod liquify;
mod lottie;
mod markers;
@@ -37,6 +38,7 @@ pub mod mask_interp;
pub(crate) mod model3d;
pub mod paint;
pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
mod project_items;
mod prop;
@@ -60,7 +62,10 @@ pub mod time;
mod track;
mod view;
pub mod viewer_cmds;
pub(crate) mod vpe;
pub(crate) mod vr_editor;
mod warp_cmds;
pub(crate) mod watch_folder;
#[cfg(test)]
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
@@ -161,6 +166,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(file_more::specs());
v.extend(lottie::specs());
v.extend(comp_more::specs());
v.extend(frame_export::specs());
v.extend(watch_folder::specs());
v.extend(vpe::specs());
v.extend(path_nulls::specs());
v.extend(vr_editor::specs());
v.extend(frontend::specs());
v.extend(track::specs());
v.extend(mask_interp::specs());
+217
View File
@@ -0,0 +1,217 @@
//! Window ▸ Create Nulls From Paths: our own panel (and these commands) linking a mask or shape
//! path to null layers with expressions, in three ways:
//!
//! - **Points Follow Nulls** (`paths.pointsFollowNulls`): one null per vertex, placed on it; the
//! path's expression rebuilds it from the nulls (`createPath` of each null's position brought
//! into the path's layer with `fromComp`), so moving a null moves its vertex.
//! - **Nulls Follow Points** (`paths.nullsFollowPoints`): one null per vertex whose Position
//! expression follows the vertex (`toComp(path.points()[i])`), for attaching things to an
//! animated path.
//! - **Trace Path** (`paths.tracePath`): one null with a Progress slider (keyframed 0 → 100 % over
//! the layer's span, optionally looping) whose Position follows `pointOnPath(progress)`.
//!
//! The path is `path`/`prop` of `layer`, else the selected path property, else the layer's first
//! mask or shape path. Each command is one undo step.
use effectcraft_geom::vec2;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerId, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, has_layers};
use crate::{EngineError, Result, Session, cmd};
fn quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
/// The target path property: (comp, layer, uid).
fn target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
if p.get("path").is_some() || p.get("prop").is_some() {
let r = super::prop::prop_ref(s, p, cmd)?;
let is_path = s.project.comp(r.0).and_then(|c| c.layer(r.1)).and_then(|l| l.props.find(r.2)).is_some_and(|pr| matches!(pr.value, KV::Path(_)));
return if is_path { Ok(r) } else { Err(bad(cmd, "that property is not a mask or shape path")) };
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// A selected path property.
for (l, u) in super::selected_leaf_props(s) {
if comp.layer(l).and_then(|ly| ly.props.find(u)).is_some_and(|pr| matches!(pr.value, KV::Path(_))) {
return Ok((cid, l, u));
}
}
// The first path of `layer` (or of the first selected layer).
let lid = match p.get("layer") {
Some(_) => super::layer_p(s, p, cmd)?.1,
None => *s.state.selected_layers.first().ok_or_else(|| bad(cmd, "select a layer with a mask or shape path"))?,
};
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let mut found = None;
layer.props.walk("", &mut |_, pr| {
if found.is_none() && matches!(pr.value, KV::Path(_)) && !matches!(pr.ui, effectcraft_project::ParamUi::Hidden) {
found = Some(pr.uid);
}
});
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, format!("`{}` has no mask or shape path", layer.name)))
}
/// What the commands need about the path: its vertices in comp space (now), the path value, the
/// expression reference to it and its layer's name.
struct PathInfo {
comp_pts: Vec<[f64; 2]>,
path: effectcraft_keyframe::ShapePath,
reference: String,
layer_name: String,
prop_name: String,
span: (f64, f64),
}
fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<PathInfo> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
let t = s.time();
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
if path.vertices.is_empty() {
return Err(bad(cmd, "the path has no vertices"));
}
let m = ctx.layer_to_comp(layer).0;
let comp_pts = path
.vertices
.iter()
.map(|v| {
let q = m.apply(vec2(v[0], v[1]));
[q.x, q.y]
})
.collect();
// Seen from another layer (the nulls).
let mut other = layer.clone();
other.id = LayerId(u64::MAX);
other.name = "\u{0}".into();
let reference = super::link::reference(comp, &other, layer, uid, true).ok_or_else(|| bad(cmd, "can't reference that path"))?;
let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone());
Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) })
}
/// A new null at `at` (comp space) named `name`, made unique in the comp (expressions find
/// layers by name). Returns (layer id, name).
fn new_null(s: &mut Session, cid: ItemId, name: &str, at: [f64; 2]) -> Result<(u64, String)> {
let taken = |n: &str| s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.name == n));
let mut name = name.to_string();
let base = name.clone();
let mut k = 2;
while taken(&name) {
name = format!("{base} {k}");
k += 1;
}
let r = s.execute("layer.newNull", json!({"comp": cid.0, "name": name}))?;
let id = r.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("no null layer".into()))?;
s.execute("prop.set", json!({"comp": cid.0, "layer": id, "path": "transform/position", "value": at}))?;
Ok((id, name))
}
fn nulls_follow_points(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paths.nullsFollowPoints";
let (cid, lid, uid) = target(s, p, C)?;
let pi = info(s, cid, lid, uid, C)?;
let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| {
let mut out = vec![];
for (i, at) in pi.comp_pts.iter().enumerate() {
let (id, _) = new_null(s, cid, &format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1), *at)?;
let e = format!("var src = thisComp.layer({});\nvar p = src.toComp({}.points()[{i}]);\n[p[0], p[1]]", quote(&pi.layer_name), pi.reference);
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?;
out.push(id);
}
Ok(out)
})?;
Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid}))
}
fn points_follow_nulls(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paths.pointsFollowNulls";
let (cid, lid, uid) = target(s, p, C)?;
let pi = info(s, cid, lid, uid, C)?;
let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| {
let mut out = vec![];
let mut names = vec![];
for (i, at) in pi.comp_pts.iter().enumerate() {
let name = format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1);
let (id, name) = new_null(s, cid, &name, *at)?;
out.push(id);
names.push(quote(&name));
}
let arr = |v: &[[f64; 2]]| serde_json::to_string(v).unwrap_or_else(|_| "[]".into());
let e = format!(
"var nulls = [{}];\nvar pts = [];\nfor (var i = 0; i < nulls.length; i++) {{\n var n = thisComp.layer(nulls[i]);\n var q = fromComp(n.toComp(n.transform.anchorPoint));\n pts.push([q[0], q[1]]);\n}}\ncreatePath(pts, {}, {}, {})",
names.join(", "),
arr(&pi.path.in_tangents),
arr(&pi.path.out_tangents),
pi.path.closed
);
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "expression": e}))?;
Ok(out)
})?;
Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid}))
}
fn trace_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paths.tracePath";
let (cid, lid, uid) = target(s, p, C)?;
let pi = info(s, cid, lid, uid, C)?;
let looping = b_p(p, "loop").unwrap_or(false);
let nid = super::app_more::grouped(s, "Trace Path", |s| {
let (id, _) = new_null(s, cid, &format!("Trace {}: {}", pi.layer_name, pi.prop_name), pi.comp_pts[0])?;
s.execute("effect.apply", json!({"comp": cid.0, "layers": [id], "effect": "ec.control.slider"}))?;
let fx = s.project.comp(cid).and_then(|c| c.layer(LayerId(id))).and_then(|l| l.props.group("effects/#1")).map(|g| g.uid);
if let Some(fx) = fx {
s.execute("prop.renameGroup", json!({"comp": cid.0, "layer": id, "prop": fx, "name": "Progress"}))?;
}
let (a, b) = pi.span;
s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": a, "value": 0.0}))?;
s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": b.max(a), "value": 100.0}))?;
if looping {
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "expression": "loopOut(\"cycle\")"}))?;
}
let e = format!(
"var src = thisComp.layer({});\nvar p = src.toComp({}.pointOnPath(effect(\"Progress\")(\"Slider\") / 100));\n[p[0], p[1]]",
quote(&pi.layer_name),
pi.reference
);
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?;
Ok(id)
})?;
Ok(json!({"null": nid, "layer": lid.0, "prop": uid}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paths.pointsFollowNulls",
"Points Follow Nulls",
[],
None,
"{layer?, path?|prop?} → a null per vertex; the path follows them (expression)",
has_layers,
points_follow_nulls
),
cmd!(
"paths.nullsFollowPoints",
"Nulls Follow Points",
[],
None,
"{layer?, path?|prop?} → a null per vertex following it (Position expressions)",
has_layers,
nulls_follow_points
),
cmd!(
"paths.tracePath",
"Trace Path",
[],
None,
"{layer?, path?|prop?, loop?: bool} → a null moving along the path (Progress slider)",
has_layers,
trace_path
),
]
}
+29
View File
@@ -0,0 +1,29 @@
//! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always
//! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public
//! specification, and EffectCraft implements formats only from published specs (clean room), so
//! the entry explains that instead of importing. See docs/parity.md.
use serde_json::Value;
use super::{CommandSpec, bad};
use crate::{Result, Session, cmd};
/// Why the entry is disabled (shown as its tooltip).
pub const WHY: &str =
"Vanishing Point Exchange (.vpe) files can't be imported: the format has no public specification, and EffectCraft only implements documented formats";
fn import(_: &mut Session, _: &Value) -> Result<Value> {
Err(bad("file.importVanishingPoint", WHY))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"file.importVanishingPoint",
"Vanishing Point (.vpe)...",
["File", "Import"],
None,
"{} (always disabled: undocumented format)",
|_| Err(WHY.into()),
import
)]
}
+167
View File
@@ -0,0 +1,167 @@
//! Window ▸ VR Comp Editor: our own panel over the VR environments Composition ▸ VR ▸ Create VR
//! Environment builds (an equirectangular output comp ← a 3:2 cube map ← six face comps, each
//! with a 90° camera). It lists the environments and edits the 360 view's camera orientation:
//! the six face cameras turn together, so the whole equirectangular output rotates (pan / tilt /
//! roll the viewer's starting direction) without touching the scene.
//!
//! The view orientation is not stored separately: it is read back from the Front camera
//! (`camera = view × face base`), so editing a camera by hand shows up here too.
use effectcraft_geom::{Mat4, vec3};
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Project};
use serde_json::{Value, json};
use super::app_more::{CELLS_3X2, FACES, ae, grouped, orientation_for_axes};
use super::{CommandSpec, always, bad};
use crate::{EngineError, Result, Session, cmd, query};
/// A VR environment: its output comp, cube map and the face cameras in [`FACES`] order.
pub struct VrEnv {
pub output: ItemId,
pub cube: ItemId,
pub faces: [(ItemId, LayerId); 6],
}
/// The VR environments of a project.
pub fn environments(p: &Project) -> Vec<VrEnv> {
let mut out = vec![];
for (oid, oc) in p.comps() {
for l in &oc.layers {
let LayerSource::Comp { item: cube } = l.source else { continue };
let vr = l.effects().is_some_and(|fx| fx.groups().any(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.vr.converter")));
if !vr {
continue;
}
let Some(cc) = p.comp(cube) else { continue };
let size = (cc.width / 3).max(1) as f64;
let mut faces: [Option<(ItemId, LayerId)>; 6] = [None; 6];
for fl in &cc.layers {
let LayerSource::Comp { item: face } = fl.source else { continue };
let pos = fl.props.prop("transform/position").map(|x| x.value.components()).unwrap_or_default();
let (Some(x), Some(y)) = (pos.first(), pos.get(1)) else { continue };
let cell = ((x / size).floor().max(0.0) as u32, (y / size).floor().max(0.0) as u32);
let Some(i) = CELLS_3X2.iter().position(|c| *c == cell) else { continue };
let cam = p.comp(face).and_then(|fc| fc.layers.iter().find(|x| x.is_camera())).map(|x| x.id);
if let Some(cam) = cam {
faces[i] = Some((face, cam));
}
}
if faces.iter().all(Option::is_some) {
out.push(VrEnv { output: *oid, cube, faces: faces.map(|f| f.unwrap_or((ItemId(0), LayerId(0)))) });
}
}
}
out
}
fn base(i: usize) -> [f64; 3] {
let (_, fw, r, u) = FACES[i];
orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw))
}
fn orient(o: [f64; 3]) -> Mat4 {
Mat4::orientation(vec3(o[0], o[1], o[2]))
}
fn transpose(m: Mat4) -> Mat4 {
let mut t = Mat4::IDENTITY;
for r in 0..3 {
for c in 0..3 {
t.0[r][c] = m.0[c][r];
}
}
t
}
fn norm(a: f64) -> f64 {
let r = a.rem_euclid(360.0);
let r = if (r - 360.0).abs() < 1e-7 { 0.0 } else { r };
(r * 1e6).round() / 1e6
}
/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix.
pub fn euler(m: Mat4) -> [f64; 3] {
let m = m.0;
let y = (-m[2][0]).clamp(-1.0, 1.0).asin();
let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) };
[norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())]
}
/// The current view orientation of an environment (from its Front camera).
fn view_of(p: &Project, env: &VrEnv) -> [f64; 3] {
let (fc, cam) = env.faces[4];
let o = p.comp(fc).and_then(|c| c.layer(cam)).and_then(|l| l.props.prop("transform/orientation")).map(|x| x.value.components()).unwrap_or_default();
let o = [o.first().copied().unwrap_or(0.0), o.get(1).copied().unwrap_or(0.0), o.get(2).copied().unwrap_or(0.0)];
euler(orient(o) * transpose(orient(base(4))))
}
fn env_json(p: &Project, e: &VrEnv) -> Value {
let name = |i: ItemId| p.item(i).map(|x| x.name.clone()).unwrap_or_default();
json!({
"output": e.output.0, "name": name(e.output), "cubeMap": e.cube.0,
"faces": e.faces.iter().enumerate().map(|(i, (c, cam))| json!({"face": FACES[i].0, "comp": c.0, "camera": cam.0})).collect::<Vec<_>>(),
"view": view_of(p, e),
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::<Vec<_>>()))
}
/// The environment `comp` (its output, cube map or a face comp), else the active comp's, else
/// the only one.
fn env_p(s: &Session, p: &Value, cmd: &str) -> Result<VrEnv> {
let envs = environments(&s.project);
let want = p.get("comp").and_then(Value::as_u64).map(ItemId).or(s.state.active_comp);
let hit = |e: &VrEnv, c: ItemId| e.output == c || e.cube == c || e.faces.iter().any(|f| f.0 == c);
let i = want.and_then(|c| envs.iter().position(|e| hit(e, c))).or((envs.len() == 1).then_some(0));
let i = i.ok_or_else(|| {
bad(cmd, if envs.is_empty() { "no VR environment (Composition ▸ VR ▸ Create VR Environment…)" } else { "give `comp`: a VR environment's output comp" })
})?;
envs.into_iter().nth(i).ok_or(EngineError::NoComp)
}
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.vr.setView";
let env = env_p(s, p, C)?;
let mut o = view_of(&s.project, &env);
match p.get("orientation") {
Some(Value::Array(a)) => {
for (k, v) in a.iter().take(3).enumerate() {
o[k] = v.as_f64().ok_or_else(|| bad(C, "orientation: [x, y, z] degrees"))?;
}
}
Some(_) => return Err(bad(C, "orientation: [x, y, z] degrees")),
None => {}
}
// Pan (Y), tilt (X) and roll (Z) by name.
for (k, i) in [("tilt", 0), ("pan", 1), ("roll", 2)] {
if let Some(v) = p.get(k).and_then(Value::as_f64) {
o[i] = v;
}
}
let v = orient(o);
grouped(s, "VR View Orientation", |s| {
for (i, (fc, cam)) in env.faces.iter().enumerate() {
let e = euler(v * orient(base(i)));
s.execute("prop.set", json!({"comp": fc.0, "layer": cam.0, "path": "transform/orientation", "value": e}))?;
}
Ok(())
})?;
Ok(json!({"output": env.output.0, "view": view_of(&s.project, &env)}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("comp.vr.environments", "VR Environments", "{} → [{output, name, cubeMap, faces: [{face, comp, camera}], view: [x, y, z]}]", list),
cmd!(
"comp.vr.setView",
"VR View Orientation",
[],
None,
"{comp? (an environment's output / cube map / face comp), orientation?: [x, y, z] degrees, pan?, tilt?, roll?} → turns the six face cameras together",
always,
set_view
),
]
}
+154
View File
@@ -0,0 +1,154 @@
//! File ▸ Watch Folder…: render farm without a farm. EffectCraft watches a folder for project
//! files (`.ecproj`, at the top level or one folder down, e.g. the folders File ▸ Dependencies ▸
//! Collect Files writes) whose Render Queue has queued items, renders them, and writes a status
//! file next to each project (`<project>.status.json`: `rendering`, then `done` or `failed`, with
//! every item's status and output path). A project with a status file is not picked up again;
//! delete the status file to render it once more.
//!
//! [`poll`] does one pass and is what the frontends call on a timer while a folder is watched
//! (`file.watchFolder.poll`); it is headless and synchronous, so tests and the CLI use it
//! directly.
use std::path::{Path, PathBuf};
use effectcraft_project::render_queue::RenderStatus;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{Result, Session, cmd};
/// The status file of a project.
pub fn status_path(project: &Path) -> PathBuf {
let mut s = project.as_os_str().to_owned();
s.push(".status.json");
PathBuf::from(s)
}
/// Projects in `folder` (and its direct subfolders) without a status file, sorted.
pub fn pending(folder: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = vec![];
let scan = |dir: &Path, out: &mut Vec<PathBuf>, subdirs: &mut Vec<PathBuf>| -> std::io::Result<()> {
for e in std::fs::read_dir(dir)? {
let p = e?.path();
if p.is_dir() {
subdirs.push(p);
} else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecproj")) && !status_path(&p).exists() {
out.push(p);
}
}
Ok(())
};
let mut subdirs = vec![];
scan(folder, &mut out, &mut subdirs)?;
for d in subdirs {
let mut ignore = vec![];
let _ = scan(&d, &mut out, &mut ignore);
}
out.sort();
Ok(out)
}
fn write_status(path: &Path, v: &Value) {
let _ = std::fs::write(status_path(path), serde_json::to_vec_pretty(v).unwrap_or_default());
}
fn now() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Render one project (in its own session sharing `host`'s services) and write its status.
fn render_project(host: &Session, path: &Path) -> Value {
let name = path.to_string_lossy().to_string();
write_status(path, &json!({"project": name, "state": "rendering", "started": now()}));
let mut s = Session { services: host.services.clone(), footage: host.footage.clone(), expr: host.expr.clone(), ..Default::default() };
s.importer = host.importer.clone();
s.exporter = host.exporter.clone();
s.accel = host.accel.clone();
s.prefs = host.prefs.clone();
let result = (|| -> std::result::Result<Value, String> {
s.execute("file.open", json!({"path": name})).map_err(|e| e.to_string())?;
let queued = s.project.render_queue.iter().filter(|i| i.render && i.status == RenderStatus::Queued).count();
if queued == 0 {
return Ok(json!({"state": "done", "items": [], "note": "nothing queued"}));
}
s.start_render(true)?;
let items: Vec<Value> = s
.project
.render_queue
.iter()
.map(|i| {
json!({
"comp": s.project.item(i.comp).map(|c| c.name.clone()),
"status": i.status.label(),
"error": if let RenderStatus::Failed(e) = &i.status { Some(e.clone()) } else { None },
"output": i.last_output,
})
})
.collect();
let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_)));
Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items}))
})();
let mut v = match result {
Ok(v) => v,
Err(e) => json!({"state": "failed", "error": e, "items": []}),
};
v["project"] = json!(name);
v["finished"] = json!(now());
write_status(path, &v);
v
}
/// One pass over `folder`: render every new project; returns their statuses.
pub fn poll(s: &mut Session, folder: &str) -> std::result::Result<Vec<Value>, String> {
let projects = pending(Path::new(folder)).map_err(|e| format!("cannot read {folder}: {e}"))?;
Ok(projects.iter().map(|p| render_project(s, p)).collect())
}
fn watch(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.watchFolder";
if p.get("stop").and_then(Value::as_bool) == Some(true) {
s.state.watch_folder = None;
s.bump();
return Ok(json!({"watching": null}));
}
let folder = str_p(p, "folder").or(str_p(p, "path")).ok_or_else(|| bad(C, "missing `folder`"))?.to_string();
if !Path::new(&folder).is_dir() {
return Err(bad(C, format!("{folder} is not a folder")));
}
s.state.watch_folder = Some(folder.clone());
s.bump();
// The first pass right away.
let done = poll(s, &folder).map_err(|e| bad(C, e))?;
s.toast(format!("Watching {folder}"));
Ok(json!({"watching": folder, "rendered": done}))
}
fn poll_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.watchFolder.poll";
let folder = str_p(p, "folder").map(str::to_string).or_else(|| s.state.watch_folder.clone()).ok_or_else(|| bad(C, "no folder is being watched"))?;
let done = poll(s, &folder).map_err(|e| bad(C, e))?;
Ok(json!({"watching": folder, "rendered": done}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.watchFolder",
"Watch Folder...",
["File"],
None,
"{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `<project>.status.json`",
always,
watch
),
cmd!(
"file.watchFolder.poll",
"Poll Watch Folder",
[],
None,
"{folder? (default: the watched one)} → renders new projects now: [{project, state: done|failed, items: [{comp, status, output}]}]",
|s: &Session| if s.state.watch_folder.is_some() { Ok(()) } else { Err("no folder is being watched".into()) },
poll_cmd
),
]
}
+8
View File
@@ -218,6 +218,10 @@ pub struct EditorState {
/// Essential Graphics can expose).
#[serde(default)]
pub essential_solo: bool,
/// File ▸ Watch Folder: the folder being watched for projects to render (frontends call
/// `file.watchFolder.poll` periodically while it is set).
#[serde(default)]
pub watch_folder: Option<String>,
/// The Footage panel: the footage shown, its time and In/Out marks (source time).
#[serde(default)]
pub footage_panel: Option<commands::footage_panel::FootageView>,
@@ -791,8 +795,12 @@ mod tests_face;
#[cfg(test)]
mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;
#[cfg(test)]
mod tests_m145;
#[cfg(test)]
mod tests_markers;
+9
View File
@@ -505,6 +505,7 @@ File
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
Lottie... | file.importLottie
Vanishing Point (.vpe)... | file.importVanishingPoint
Essential Graphics Template... | essential.importTemplate
Import Recent Footage
@dynamic:recentFootage
@@ -528,6 +529,7 @@ File
Find Missing Effects | file.findMissing {"what":"effects"}
Find Missing Fonts | file.findMissing {"what":"fonts"}
Find Missing Footage | file.findMissing {"what":"footage"}
Watch Folder... | file.watchFolder
---
Scripts
@dynamic:scripts
@@ -664,6 +666,8 @@ Composition
Audio | playback.audio
Save Frame As
File... | comp.saveFrameAs
Photoshop Layers... | comp.saveFrameAsPsd
ProEXR... | comp.saveFrameAsExr
Pre-render... | render.preRender
Save Current Preview... | render.saveCurrentPreview
---
@@ -1166,6 +1170,8 @@ Window
Timeline | window.panel {"panel":"timeline"}
@dynamic:openViewers
---
Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"}
VR Comp Editor | window.panel {"panel":"vrCompEditor"}
@dynamic:scriptPanels
Help
EffectCraft Help... | help.docs {"page":"help"} | F1
@@ -1175,6 +1181,9 @@ Help
Animation Presets... | anim.browsePresets
Keyboard Shortcuts... | app.keyboardShortcuts
---
In-App Tutorials... | help.inAppTutorials
Online Tutorials... | help.onlineTutorials
---
System Compatibility Report... | help.systemReport
Enable Logging | help.enableLogging
Reveal Logging File | help.revealLogFile
+78
View File
@@ -0,0 +1,78 @@
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
use serde_json::json;
use crate::Session;
fn out_dir(name: &str) -> std::path::PathBuf {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A 32×32 comp: a red full-frame solid under a 16×16 blue Multiply solid at 50%.
fn frame() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap();
let blue = s.execute("layer.newSolid", json!({"name": "Blue.Box", "color": "#0000ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setBlendMode", json!({"layers": [blue], "mode": "Multiply"})).unwrap();
s.execute("prop.set", json!({"layer": blue, "path": "transform/opacity", "value": 50})).unwrap();
s
}
#[test]
fn photoshop_layers_writes_one_psd_layer_per_comp_layer() {
let mut s = frame();
let dir = out_dir("frame-psd");
let path = dir.join("frame.psd").to_string_lossy().to_string();
let r = s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
assert_eq!(r["layers"], 2);
let psd = effectcraft_psd::Psd::parse(std::fs::read(&path).unwrap()).unwrap();
assert_eq!((psd.width, psd.height), (32, 32));
let names: Vec<&str> = psd.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Red", "Blue.Box"], "bottom first");
let blue = &psd.layers[1];
assert_eq!(blue.blend_key(), "mul ");
assert_eq!(blue.opacity, 128);
// Cropped to its pixels (16×16, centred) and rendered at full opacity.
assert_eq!((blue.rect.left, blue.rect.top, blue.rect.width(), blue.rect.height()), (8, 8, 16, 16));
assert_eq!(psd.layers[0].blend_key(), "norm");
assert_eq!(psd.layers[0].opacity, 255);
// The merged image is the comp frame: red × (50% blue multiply) in the middle.
let m = psd.composite().unwrap();
let mid = m.data[16 * 32 + 16];
assert!(mid[0] > 0.4 && mid[0] < 0.6 && mid[2] < 0.05, "{mid:?}");
let corner = m.data[0];
assert!(corner[0] > 0.99 && corner[2] < 0.01, "{corner:?}");
// The menu has it.
assert!(crate::commands::find("comp.saveFrameAsPsd").is_some());
}
#[test]
fn proexr_writes_layer_prefixed_channels_and_the_composite() {
use exr::prelude::*;
let mut s = frame();
let dir = out_dir("frame-exr");
let path = dir.join("frame.exr").to_string_lossy().to_string();
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
assert_eq!(names.len(), 12);
assert!(names.contains(&"R") && names.contains(&"A") && names.contains(&"Red.R") && names.contains(&"Blue_Box.B"), "{names:?}");
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap();
let layer = &img.layer_data[0];
assert_eq!((layer.size.0, layer.size.1), (32, 32));
let ch = |n: &str| layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}"));
let at = |n: &str, x: usize, y: usize| match &ch(n).sample_data {
FlatSamples::F32(v) => v[y * 32 + x],
o => panic!("{o:?}"),
};
// The blue layer alone: blue inside its box, transparent outside.
assert!((at("Blue_Box.B", 16, 16) - 1.0).abs() < 1e-3);
assert_eq!(at("Blue_Box.A", 0, 0), 0.0);
assert!((at("Red.R", 0, 0) - 1.0).abs() < 1e-3);
// Composite: opaque everywhere.
assert!((at("A", 16, 16) - 1.0).abs() < 1e-3);
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
}
+167
View File
@@ -0,0 +1,167 @@
//! M13.7 menu items: File ▸ Watch Folder, Import ▸ Vanishing Point (disabled), Create Nulls From
//! Paths, VR Comp Editor and Help ▸ In-App / Online Tutorials.
use std::sync::Arc;
use serde_json::json;
use crate::rq_tests::MockExporter;
use crate::{Event, Session};
fn out_dir(name: &str) -> std::path::PathBuf {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn watch_folder_renders_queued_projects_and_writes_status() {
let dir = out_dir("watch-folder");
let ex = Arc::new(MockExporter { log: Default::default() });
// A project with a queued render, and one (in a subfolder) with nothing queued.
let mut a = Session { exporter: Some(ex.clone()), ..Default::default() };
a.execute("comp.new", json!({"name": "Shot", "width": 32, "height": 32, "frameRate": 10, "duration": 0.5})).unwrap();
a.execute("renderQueue.add", json!({})).unwrap();
let pa = dir.join("a.ecproj");
a.execute("file.saveAs", json!({"path": pa.to_string_lossy()})).unwrap();
std::fs::create_dir_all(dir.join("collected")).unwrap();
let mut b = Session::default();
b.execute("comp.new", json!({"name": "Idle", "width": 32, "height": 32, "duration": 1})).unwrap();
let pb = dir.join("collected/b.ecproj");
b.execute("file.saveAs", json!({"path": pb.to_string_lossy()})).unwrap();
let mut host = Session { exporter: Some(ex.clone()), ..Default::default() };
assert!(!host.is_enabled("file.watchFolder.poll"));
let r = host.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap();
let done = r["rendered"].as_array().unwrap();
assert_eq!(done.len(), 2, "{r}");
assert_eq!(host.state.watch_folder.as_deref(), Some(dir.to_string_lossy().as_ref()));
let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("a.ecproj.status.json")).unwrap()).unwrap();
assert_eq!(st["state"], "done", "{st}");
assert_eq!(st["items"][0]["comp"], "Shot");
assert_eq!(st["items"][0]["status"], "Done");
assert_eq!(ex.log.lock().unwrap().len(), 1);
let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("collected/b.ecproj.status.json")).unwrap()).unwrap();
assert_eq!(st["note"], "nothing queued");
// Rendered projects are not picked up again; deleting the status file re-renders.
assert!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().is_empty());
std::fs::remove_file(dir.join("a.ecproj.status.json")).unwrap();
assert_eq!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().len(), 1);
assert_eq!(ex.log.lock().unwrap().len(), 2);
// A broken project fails with a status, not an error.
std::fs::write(dir.join("broken.ecproj"), b"not a project").unwrap();
let r = host.execute("file.watchFolder.poll", json!({})).unwrap();
assert_eq!(r["rendered"][0]["state"], "failed");
host.execute("file.watchFolder", json!({"stop": true})).unwrap();
assert!(host.state.watch_folder.is_none());
assert!(host.execute("file.watchFolder", json!({"folder": "/no/such/folder"})).is_err());
}
#[test]
fn vanishing_point_import_is_disabled_with_a_reason() {
let mut s = Session::default();
assert!(!s.is_enabled("file.importVanishingPoint"));
let spec = crate::commands::find("file.importVanishingPoint").unwrap();
assert!((spec.enabled)(&s).unwrap_err().contains("public specification"));
assert!(s.execute("file.importVanishingPoint", json!({})).is_err());
}
#[test]
fn help_tutorials() {
let mut s = Session::default();
s.drain_events();
s.execute("help.onlineTutorials", json!({})).unwrap();
s.execute("help.inAppTutorials", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE)));
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials")));
}
/// A comp with a 2D solid at (50, 40) carrying a square mask.
fn masked() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
(s, l)
}
#[test]
fn nulls_follow_points_and_points_follow_nulls() {
let (mut s, l) = masked();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
let r = s.execute("paths.nullsFollowPoints", json!({})).unwrap();
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(nulls.len(), 4);
let comp = s.active_comp().unwrap().clone();
let n2 = comp.layer(effectcraft_project::LayerId(nulls[2])).unwrap();
assert_eq!(n2.name, "Plate: Mask 1 [3]");
// Placed on the vertex in comp space (the 40×40 solid is centred at (50, 40)).
let pos = n2.props.prop("transform/position").unwrap();
assert_eq!(&pos.value.components()[..2], &[70.0, 60.0]);
let e = pos.expr.as_ref().unwrap().text.clone();
assert!(e.contains("thisComp.layer(\"Plate\")") && e.contains(".points()[2]"), "{e}");
// One undo step.
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
assert_eq!(r["nulls"].as_array().unwrap().len(), 4);
let comp = s.active_comp().unwrap().clone();
let plate = comp.layer(effectcraft_project::LayerId(l)).unwrap();
let e = plate.props.prop("masks/#1/path").unwrap().expr.as_ref().unwrap().text.clone();
assert!(e.contains("createPath(pts") && e.contains("\"Plate: Mask 1 [1]\"") && e.contains("fromComp"), "{e}");
// Not a path → error.
assert!(s.execute("paths.nullsFollowPoints", json!({"layer": l, "path": "transform/opacity"})).is_err());
}
#[test]
fn trace_path_adds_a_progress_null() {
let (mut s, l) = masked();
let r = s.execute("paths.tracePath", json!({"layer": l, "loop": true})).unwrap();
let n = r["null"].as_u64().unwrap();
let comp = s.active_comp().unwrap().clone();
let null = comp.layer(effectcraft_project::LayerId(n)).unwrap();
let slider = null.props.prop("effects/#1/slider").unwrap();
assert_eq!(slider.keys.len(), 2);
assert_eq!(slider.keys[1].value.as_f64(), 100.0);
assert!(slider.expr.as_ref().unwrap().text.contains("loopOut"));
assert_eq!(null.props.group("effects/#1").unwrap().name, "Progress");
assert!(null.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.contains("pointOnPath(effect(\"Progress\")(\"Slider\") / 100)"));
}
#[test]
fn vr_comp_editor_turns_the_face_cameras_together() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Scene", "width": 64, "height": 64, "frameRate": 10, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Wall", "color": "#ff0000"})).unwrap();
s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
let envs = s.execute("comp.vr.environments", json!({})).unwrap();
assert_eq!(envs.as_array().unwrap().len(), 1, "{envs}");
let env = &envs[0];
assert_eq!(env["name"], "Scene VR Output");
assert_eq!(env["faces"].as_array().unwrap().len(), 6);
let view = env["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!(view.iter().all(|v| v.abs() < 1e-6 || (v - 360.0).abs() < 1e-6), "{view:?}");
let out = env["output"].as_u64().unwrap();
let r = s.execute("comp.vr.setView", json!({"comp": out, "pan": 30.0})).unwrap();
let v = r["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!((v[1] - 30.0).abs() < 1e-6 && v[0].abs() < 1e-6 && v[2].abs() < 1e-6, "{v:?}");
// Every face camera turned: Front by 30° about Y.
let front = &env["faces"][4];
let fc = s.project.comp(effectcraft_project::ItemId(front["comp"].as_u64().unwrap())).unwrap();
let cam = fc.layer(effectcraft_project::LayerId(front["camera"].as_u64().unwrap())).unwrap();
let o = cam.props.prop("transform/orientation").unwrap().value.components();
assert!((o[1] - 30.0).abs() < 1e-6, "{o:?}");
// One undo step back to the start.
assert!(s.undo());
let envs = s.execute("comp.vr.environments", json!({})).unwrap();
assert!(envs[0]["view"][1].as_f64().unwrap().abs() < 1e-6);
// Round trip of the Euler decomposition.
for o in [[10.0, 20.0, 30.0], [350.0, 45.0, 5.0], [0.0, 0.0, 270.0]] {
let m = effectcraft_geom::Mat4::orientation(effectcraft_geom::vec3(o[0], o[1], o[2]));
let e = crate::commands::vr_editor::euler(m);
assert!(e.iter().zip(o).all(|(a, b)| (a - b).abs() < 1e-6), "{o:?} → {e:?}");
}
}
+49
View File
@@ -351,6 +351,52 @@ __Path.prototype.points = function () { return this.__pts; };
__Path.prototype.inTangents = function () { return this.__in; };
__Path.prototype.outTangents = function () { return this.__out; };
__Path.prototype.isClosed = function () { return this.__closed; };
// pointOnPath / tangentOnPath: a point (or unit tangent) at a fraction of the path's arc length.
__Path.prototype.pointOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, false); };
__Path.prototype.tangentOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, true); };
__Path.prototype.normalOnPath = function (pct) { var t = this.tangentOnPath(pct); return [t[1], -t[0]]; };
function __pathSegs(p) {
var n = p.__pts.length, out = [];
var m = p.__closed ? n : n - 1;
for (var i = 0; i < m; i++) {
var j = (i + 1) % n, a = p.__pts[i], b = p.__pts[j];
var o = p.__out[i] || [0, 0], ii = p.__in[j] || [0, 0];
out.push([a, [a[0] + o[0], a[1] + o[1]], [b[0] + ii[0], b[1] + ii[1]], b]);
}
return out;
}
function __bezAt(s, u) {
var v = 1 - u;
return [0, 1].map(function (k) { return v * v * v * s[0][k] + 3 * v * v * u * s[1][k] + 3 * v * u * u * s[2][k] + u * u * u * s[3][k]; });
}
function __bezDer(s, u) {
var v = 1 - u;
return [0, 1].map(function (k) { return 3 * v * v * (s[1][k] - s[0][k]) + 6 * v * u * (s[2][k] - s[1][k]) + 3 * u * u * (s[3][k] - s[2][k]); });
}
function __pathAt(p, pct, tangent) {
var segs = __pathSegs(p);
if (!segs.length) return tangent ? [1, 0] : p.__pts.length ? [p.__pts[0][0], p.__pts[0][1]] : [0, 0];
var N = 32, table = [], total = 0;
for (var k = 0; k < segs.length; k++) {
var prev = __bezAt(segs[k], 0);
for (var s = 1; s <= N; s++) {
var q = __bezAt(segs[k], s / N);
total += Math.sqrt((q[0] - prev[0]) * (q[0] - prev[0]) + (q[1] - prev[1]) * (q[1] - prev[1]));
table.push([total, k, s / N]);
prev = q;
}
}
var target = Math.max(0, Math.min(1, pct)) * total;
var i = 0;
while (i < table.length - 1 && table[i][0] < target) i++;
var e = table[i], seg = e[1];
var l0 = i > 0 ? table[i - 1][0] : 0, u0 = i > 0 && table[i - 1][1] === seg ? table[i - 1][2] : 0;
var u = e[0] > l0 ? u0 + (e[2] - u0) * (target - l0) / (e[0] - l0) : e[2];
if (!tangent) return __bezAt(segs[seg], u);
var d = __bezDer(segs[seg], u), len = Math.sqrt(d[0] * d[0] + d[1] * d[1]);
return len > 0 ? [d[0] / len, d[1] / len] : [1, 0];
}
function createPath(points, inTangents, outTangents, isClosed) {
points = points || [];
@@ -734,6 +780,9 @@ Prop.prototype = {
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
isClosed: function () { return this.value.isClosed(); },
pointOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).pointOnPath(pct); },
tangentOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).tangentOnPath(pct); },
normalOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).normalOnPath(pct); },
// Source Text style API.
__docAt: function (t) {
var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self);
+4
View File
@@ -1116,6 +1116,10 @@ fn project_and_time_globals() {
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0);
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0);
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0);
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
// Marker keys carry protected regions and cue points.
{
+26
View File
@@ -185,6 +185,32 @@ fn essential_graphics_scripting_hooks() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_nulls_from_paths_expressions_evaluate() {
let mut s = session();
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap();
s.execute("paths.tracePath", json!({"layer": l})).unwrap();
// Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex).
let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)");
let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx");
assert!(o.error.is_none(), "{:?}", o.error);
let parts: Vec<Vec<f64>> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect();
assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space");
assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex");
// Halfway in time along the closed square: the opposite corner.
assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]);
// Points Follow Nulls: moving the last vertex's null moves the vertex.
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap();
let last = r["nulls"][3].as_u64().unwrap();
s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap();
let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')";
let o = run_code(&mut s, code, "x.jsx");
assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error);
}
#[test]
fn sockets_talk_tcp_behind_the_network_preference() {
use std::io::{BufRead, BufReader, Write};
+23 -2
View File
@@ -44,10 +44,14 @@ pub enum PanelKind {
Metadata,
Progress,
ContentAwareFill,
/// Window ▸ Create Nulls From Paths (our own panel).
CreateNullsFromPaths,
/// Window ▸ VR Comp Editor (our own panel).
VrCompEditor,
}
impl PanelKind {
pub const ALL: [PanelKind; 32] = [
pub const ALL: [PanelKind; 34] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@@ -80,6 +84,8 @@ impl PanelKind {
PanelKind::Metadata,
PanelKind::Progress,
PanelKind::ContentAwareFill,
PanelKind::CreateNullsFromPaths,
PanelKind::VrCompEditor,
];
pub fn title(self) -> &'static str {
match self {
@@ -116,6 +122,8 @@ impl PanelKind {
PanelKind::Metadata => "Metadata",
PanelKind::Progress => "Progress",
PanelKind::ContentAwareFill => "Content-Aware Fill",
PanelKind::CreateNullsFromPaths => "Create Nulls From Paths",
PanelKind::VrCompEditor => "VR Comp Editor",
}
}
pub fn id(self) -> String {
@@ -342,7 +350,20 @@ pub fn workspace(name: &str) -> DockNode {
tabs(&[Composition, Layer, Footage], 0),
right(
&[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes],
&[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill],
&[
EffectsPresets,
Properties,
Tracker,
Wiggler,
Smoother,
MotionSketch,
Paint,
Brushes,
Markers,
ContentAwareFill,
CreateNullsFromPaths,
VrCompEditor,
],
),
),
),
+17
View File
@@ -905,6 +905,23 @@ impl EffectcraftApp {
if self.session.prefs.auto_save.enabled {
ctx.request_repaint_after(std::time::Duration::from_secs(30));
}
// File ▸ Watch Folder: look for new projects every 10 seconds.
if self.session.state.watch_folder.is_some() {
let key = egui::Id::new("watchFolder.lastPoll");
let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0);
if now - last >= 10.0 {
ctx.data_mut(|d| d.insert_temp(key, now));
match self.session.execute("file.watchFolder.poll", serde_json::json!({})) {
Ok(r) => {
if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) {
self.ui.status = format!("Watch Folder: rendered {n} project(s)");
}
}
Err(e) => self.ui.status = e.to_string(),
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
}
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
+9
View File
@@ -801,6 +801,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({}));
Value::Null
}
// Help ▸ In-App Tutorials: the Home screen's Learn tab.
"help.inAppTutorials" => {
app.ui.start_screen = true;
app.ui.home_learn = true;
Value::Null
}
"effect.manage" | "anim.browsePresets" => {
app.show_panel(PanelKind::EffectsPresets);
Value::Null
@@ -831,6 +837,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"file.save" if app.session.path.is_none() => ("path", Ask::Save("Untitled Project.ecproj")),
"file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")),
"comp.saveFrameAs" => ("path", Ask::Save("Frame.png")),
"comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")),
"comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")),
"file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")),
"anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")),
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
+3
View File
@@ -28,6 +28,7 @@ pub mod media_panels;
pub mod misc;
pub mod paint_panels;
pub mod panel_kit;
pub mod path_vr_panels;
pub mod precomp;
pub mod project;
pub mod properties;
@@ -117,6 +118,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Metadata => media_panels::metadata(app, ui, rect),
PanelKind::Progress => media_panels::progress(app, ui, rect),
PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect),
PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect),
PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect),
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Two built-in panels After Effects ships as scripts, written from scratch as panels:
//!
//! - **Create Nulls From Paths** (Window ▸ Create Nulls From Paths): with a mask or shape path (or
//! a layer that has one) selected, Points Follow Nulls / Nulls Follow Points / Trace Path
//! (`paths.*` commands; Trace Path can loop).
//! - **VR Comp Editor** (Window ▸ VR Comp Editor): the project's VR environments (Composition ▸ VR
//! ▸ Create VR Environment); pick one and edit its 360 view's camera orientation (pan, tilt,
//! roll) with `comp.vr.setView`; open its output.
//!
//! Automation ids: `createNulls.pointsFollowNulls`, `createNulls.nullsFollowPoints`,
//! `createNulls.tracePath`, `createNulls.loop`, `vrEditor.env.<output id>`, `vrEditor.pan`,
//! `vrEditor.tilt`, `vrEditor.roll`, `vrEditor.reset`, `vrEditor.open`.
use egui::{Rect, RichText};
use serde_json::{Value, json};
use crate::EffectcraftApp;
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, Value)>) {
for (id, p) in actions {
match crate::menus::invoke(app, ctx, id, p) {
Ok(_) => {}
Err(e) => app.ui.status = e,
}
}
}
fn area(ui: &mut egui::Ui, rect: Rect) -> egui::Ui {
ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)))
}
/// Window ▸ Create Nulls From Paths.
pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut ui = area(ui, rect);
let loop_id = egui::Id::new("createNulls.loop");
let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false);
let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty();
ui.label(RichText::new("Select a layer with a mask or shape path (or the path property).").color(t.text_dim).small());
ui.add_space(6.0);
let mut acts = vec![];
for (id, label, tip) in [
("paths.pointsFollowNulls", "Points Follow Nulls", "A null on each vertex; move the nulls to reshape the path"),
("paths.nullsFollowPoints", "Nulls Follow Points", "A null on each vertex that follows the (animated) path"),
("paths.tracePath", "Trace Path", "A null moving along the path, driven by its Progress slider"),
] {
let r = ui.add_enabled(ready, egui::Button::new(label).min_size(egui::vec2(170.0, 24.0))).on_hover_text(tip);
let auto = format!("createNulls.{}", id.trim_start_matches("paths."));
app.auto.add(&auto, r.rect, label);
if r.clicked() {
acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) }));
}
}
let r = ui.checkbox(&mut looping, "Loop (Trace Path)");
app.auto.add("createNulls.loop", r.rect, "Loop");
ui.data_mut(|d| d.insert_temp(loop_id, looping));
run(app, &ctx, acts);
}
/// Window ▸ VR Comp Editor.
pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut ui = area(ui, rect);
let envs = app.session.execute("comp.vr.environments", json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
if envs.is_empty() {
ui.label(RichText::new("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…").color(t.text_faint));
return;
}
let sel_id = egui::Id::new("vrEditor.selected");
let active = app.session.state.active_comp.map(|c| c.0);
let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0);
// Default: the active comp's environment, else the first.
let owns = |e: &Value, c: u64| e["output"] == c || e["cubeMap"] == c || e["faces"].as_array().is_some_and(|f| f.iter().any(|x| x["comp"] == c));
if !envs.iter().any(|e| e["output"] == sel) {
sel = active.and_then(|c| envs.iter().find(|e| owns(e, c))).or(envs.first()).and_then(|e| e["output"].as_u64()).unwrap_or(0);
}
let mut acts = vec![];
ui.label(RichText::new("VR Environments").strong());
for e in &envs {
let out = e["output"].as_u64().unwrap_or(0);
let r = ui.selectable_label(out == sel, e["name"].as_str().unwrap_or(""));
app.auto.add(&format!("vrEditor.env.{out}"), r.rect, e["name"].as_str().unwrap_or(""));
if r.clicked() {
sel = out;
}
}
ui.data_mut(|d| d.insert_temp(sel_id, sel));
let Some(env) = envs.iter().find(|e| e["output"] == sel) else { return };
ui.separator();
ui.label(RichText::new("360 View Orientation").strong());
let view: Vec<f64> = env["view"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let signed = |a: f64| if a > 180.0 { a - 360.0 } else { a };
let (mut tilt, mut pan, mut roll) =
(signed(view.first().copied().unwrap_or(0.0)), signed(view.get(1).copied().unwrap_or(0.0)), signed(view.get(2).copied().unwrap_or(0.0)));
let mut changed = false;
for (label, auto, v) in [("Pan", "vrEditor.pan", &mut pan), ("Tilt", "vrEditor.tilt", &mut tilt), ("Roll", "vrEditor.roll", &mut roll)] {
ui.horizontal(|ui| {
ui.label(label);
let r = ui.add(egui::Slider::new(v, -180.0..=180.0).suffix("°").max_decimals(1));
app.auto.add(auto, r.rect, label);
changed |= r.changed();
});
}
if changed {
acts.push(("comp.vr.setView", json!({"comp": sel, "pan": pan, "tilt": tilt, "roll": roll})));
}
ui.horizontal(|ui| {
let r = ui.button("Reset");
app.auto.add("vrEditor.reset", r.rect, "Reset");
if r.clicked() {
acts.push(("comp.vr.setView", json!({"comp": sel, "orientation": [0.0, 0.0, 0.0]})));
}
let r = ui.button("Open Output");
app.auto.add("vrEditor.open", r.rect, "Open Output");
if r.clicked() {
acts.push(("comp.open", json!({"comp": sel})));
}
});
ui.label(RichText::new("All six face cameras turn together; the scene is not changed.").color(t.text_faint).small());
run(app, &ctx, acts);
}