From 0698b23cd7e0387742608e810bafe0155ce67422 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:55:04 -0400 Subject: [PATCH] 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 --- crates/engine/Cargo.toml | 1 + crates/engine/src/commands/app_more.rs | 8 +- crates/engine/src/commands/frame_export.rs | 240 ++++++++++++++++++++ crates/engine/src/commands/frontend.rs | 2 +- crates/engine/src/commands/help.rs | 6 + crates/engine/src/commands/mod.rs | 14 +- crates/engine/src/commands/path_nulls.rs | 217 ++++++++++++++++++ crates/engine/src/commands/vpe.rs | 29 +++ crates/engine/src/commands/vr_editor.rs | 167 ++++++++++++++ crates/engine/src/commands/watch_folder.rs | 154 +++++++++++++ crates/engine/src/lib.rs | 8 + crates/engine/src/menus.rs | 9 + crates/engine/src/tests_frame_export.rs | 78 +++++++ crates/engine/src/tests_m137.rs | 167 ++++++++++++++ crates/expr/src/prelude.js | 49 ++++ crates/expr/src/tests.rs | 4 + crates/script/src/tests_ui_more.rs | 26 +++ crates/ui-egui/src/dock.rs | 25 +- crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/menus.rs | 9 + crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/path_vr_panels.rs | 123 ++++++++++ 22 files changed, 1347 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/commands/frame_export.rs create mode 100644 crates/engine/src/commands/path_nulls.rs create mode 100644 crates/engine/src/commands/vpe.rs create mode 100644 crates/engine/src/commands/vr_editor.rs create mode 100644 crates/engine/src/commands/watch_folder.rs create mode 100644 crates/engine/src/tests_frame_export.rs create mode 100644 crates/engine/src/tests_m137.rs create mode 100644 crates/ui-egui/src/panels/path_vr_panels.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7a7adb30b..6381de0bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,3 +34,4 @@ log = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } +exr = { workspace = true } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index fb0473d4f..414fae223 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result { /// 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]) } diff --git a/crates/engine/src/commands/frame_export.rs b/crates/engine/src/commands/frame_export.rs new file mode 100644 index 000000000..77e0f5d6d --- /dev/null +++ b/crates/engine/src/commands/frame_export.rs @@ -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 `.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)> { + 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 = 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, 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 { + 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, Vec, 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; 4] { + let mut out: [Vec; 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 { + 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 { + 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 { + 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 + `.R/G/B/A` per layer (linear, premultiplied)", + has_comp, + save_exr + ), + ] +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 7abd29238..368af0e3e 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "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!( diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/commands/help.rs index 2ed358b54..0e9dd04d2 100644 --- a/crates/engine/src/commands/help.rs +++ b/crates/engine/src/commands/help.rs @@ -35,6 +35,12 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 9ef10e095..54232fe1f 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/commands/path_nulls.rs new file mode 100644 index 000000000..1034f3d50 --- /dev/null +++ b/crates/engine/src/commands/path_nulls.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/commands/vpe.rs new file mode 100644 index 000000000..f4932bc01 --- /dev/null +++ b/crates/engine/src/commands/vpe.rs @@ -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 { + Err(bad("file.importVanishingPoint", WHY)) +} + +pub fn specs() -> Vec { + vec![cmd!( + "file.importVanishingPoint", + "Vanishing Point (.vpe)...", + ["File", "Import"], + None, + "{} (always disabled: undocumented format)", + |_| Err(WHY.into()), + import + )] +} diff --git a/crates/engine/src/commands/vr_editor.rs b/crates/engine/src/commands/vr_editor.rs new file mode 100644 index 000000000..e62281128 --- /dev/null +++ b/crates/engine/src/commands/vr_editor.rs @@ -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 { + 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::>(), + "view": view_of(p, e), + }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::>())) +} + +/// 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 { + 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/watch_folder.rs b/crates/engine/src/commands/watch_folder.rs new file mode 100644 index 000000000..b10cf8d50 --- /dev/null +++ b/crates/engine/src/commands/watch_folder.rs @@ -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 (`.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> { + let mut out = vec![]; + let scan = |dir: &Path, out: &mut Vec, subdirs: &mut Vec| -> 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 { + 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 = 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, 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 { + 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 { + 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 { + vec![ + cmd!( + "file.watchFolder", + "Watch Folder...", + ["File"], + None, + "{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `.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 + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d299e350e..1c3b2949f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// The Footage panel: the footage shown, its time and In/Out marks (source time). #[serde(default)] pub footage_panel: Option, @@ -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; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 57a45ab15..b991be9b0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs new file mode 100644 index 000000000..4866619de --- /dev/null +++ b/crates/engine/src/tests_frame_export.rs @@ -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)"); +} diff --git a/crates/engine/src/tests_m137.rs b/crates/engine/src/tests_m137.rs new file mode 100644 index 000000000..13abe9c64 --- /dev/null +++ b/crates/engine/src/tests_m137.rs @@ -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 = 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::>(); + 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::>(); + 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:?}"); + } +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 39770ee1e..fe95d0974 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -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); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d75972f76..ac06485ed 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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. { diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 153533c44..2c24329ac 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -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> = 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}; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 28c048374..1fb2ef790 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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, + ], ), ), ), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 48587f9e1..ffa143931 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3800ef6a..2e58697bf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 ("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")), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d82c6d972..8ebd6ad30 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), } } diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs new file mode 100644 index 000000000..1fc5b808a --- /dev/null +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -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.`, `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 = 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); +}