From 5aae82b05c9466f523baf0429a55288d9dfb3a77 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:49:05 -0400 Subject: [PATCH 1/4] M13.7: ScriptUI resource strings, onDraw graphics, live onChanging, Socket Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/scripts.rs | 4 +- crates/engine/src/scriptui.rs | 122 ++++++++- crates/script/src/lib.rs | 11 +- crates/script/src/prelude.js | 74 ++++- crates/script/src/runtime.rs | 5 +- crates/script/src/scriptui.js | 304 ++++++++++++++++++++- crates/script/src/socket.rs | 241 ++++++++++++++++ crates/script/src/tests_ui.rs | 6 +- crates/script/src/tests_ui_more.rs | 173 ++++++++++++ crates/ui-egui/src/panels/scriptui_view.rs | 82 +++++- 10 files changed, 995 insertions(+), 27 deletions(-) create mode 100644 crates/script/src/socket.rs create mode 100644 crates/script/src/tests_ui_more.rs diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 628533af..49884bc0 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -188,7 +188,7 @@ pub fn specs() -> Vec { crate::query!( "scriptui.get", "Script Window Controls", - "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}", + "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}", scriptui::get ), cmd!( @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "Set Script Window Control", [], None, - "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", + "{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", always, scriptui::set ), diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 2a76ba04..1b88bce7 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -112,9 +112,103 @@ pub struct Widget { pub handlers: Vec, /// The active tab of a tabbed panel (child index). pub active_tab: usize, + /// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates. + /// Frontends paint it instead of the control's default look, unless it contains + /// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first. + pub draw: Vec, pub children: Vec, } +/// One segment of a ScriptUIGraphics path (control coordinates, pixels). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "k")] +pub enum PathSeg { + /// `moveTo(x, y)`: starts a new subpath. + M { x: f64, y: f64 }, + /// `lineTo(x, y)`. + L { x: f64, y: f64 }, + /// `rectPath(x, y, w, h)`: a closed rectangle subpath. + R { x: f64, y: f64, w: f64, h: f64 }, + /// `ellipsePath(x, y, w, h)`: a closed ellipse inscribed in the rectangle. + E { x: f64, y: f64, w: f64, h: f64 }, + /// `closePath()`. + Z, +} + +/// One paint call of an `onDraw` handler. Colours are straight RGBA in 0..1; `None` is a theme +/// colour (a brush or pen made from a theme colour name). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum DrawOp { + /// `fillPath(brush, path)`. + Fill { color: Option<[f32; 4]>, path: Vec }, + /// `strokePath(pen, path)`. + Stroke { color: Option<[f32; 4]>, width: f64, path: Vec }, + /// `drawString(text, pen, x, y, font)`: `(x, y)` is the text's top-left. + Text { + text: String, + color: Option<[f32; 4]>, + x: f64, + y: f64, + size: f64, + #[serde(default)] + style: String, + }, + /// `drawOSControl()`: the control's default look. + Os, + /// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame). + Image { x: f64, y: f64, w: f64, h: f64 }, +} + +impl PathSeg { + /// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with + /// whether it is closed. + pub fn polylines(path: &[PathSeg], n: usize) -> Vec<(Vec<[f64; 2]>, bool)> { + fn flush(cur: &mut Vec<[f64; 2]>, out: &mut Vec<(Vec<[f64; 2]>, bool)>, closed: bool) { + if cur.len() > 1 { + out.push((std::mem::take(cur), closed)); + } else { + cur.clear(); + } + } + let mut out = vec![]; + let mut cur: Vec<[f64; 2]> = vec![]; + for seg in path { + match *seg { + PathSeg::M { x, y } => { + flush(&mut cur, &mut out, false); + cur.push([x, y]); + } + PathSeg::L { x, y } => cur.push([x, y]), + PathSeg::Z => { + let start = cur.first().copied(); + flush(&mut cur, &mut out, true); + // Drawing continues from the subpath's start. + cur.extend(start); + } + PathSeg::R { x, y, w, h } => { + flush(&mut cur, &mut out, false); + out.push((vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]], true)); + } + PathSeg::E { x, y, w, h } => { + flush(&mut cur, &mut out, false); + let (cx, cy, rx, ry) = (x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0); + let n = n.max(8); + let pts = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + [cx + rx * a.cos(), cy + ry * a.sin()] + }) + .collect(); + out.push((pts, true)); + } + } + } + flush(&mut cur, &mut out, false); + out + } +} + impl Widget { pub fn find(&self, id: u32) -> Option<&Widget> { if self.id == id { @@ -493,7 +587,8 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?; value = json!(i); } - dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value }) + let kind = if p.get("changing").and_then(Value::as_bool) == Some(true) { "changing" } else { "change" }; + dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value }) } /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). @@ -557,6 +652,31 @@ mod tests { assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1); } + #[test] + fn draw_lists_flatten_to_polylines_and_round_trip() { + let path = vec![ + PathSeg::M { x: 0.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 10.0 }, + PathSeg::Z, + PathSeg::R { x: 1.0, y: 2.0, w: 3.0, h: 4.0 }, + PathSeg::E { x: 0.0, y: 0.0, w: 20.0, h: 10.0 }, + ]; + let lines = PathSeg::polylines(&path, 16); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], (vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true)); + assert_eq!(lines[1].0[2], [4.0, 6.0]); + assert_eq!(lines[2].0.len(), 16); + assert!((lines[2].0[0][0] - 20.0).abs() < 1e-9 && (lines[2].0[4][1] - 10.0).abs() < 1e-9); + let op = DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path }; + let j = serde_json::to_value(&op).unwrap(); + assert_eq!(j["op"], "fill"); + assert_eq!(j["path"][0], json!({"k": "M", "x": 0.0, "y": 0.0})); + assert_eq!(serde_json::from_value::(j).unwrap(), op); + let t: DrawOp = serde_json::from_value(json!({"op": "text", "text": "Hi", "color": null, "x": 1, "y": 2, "size": 14})).unwrap(); + assert!(matches!(t, DrawOp::Text { size, .. } if size == 14.0)); + } + #[test] fn publish_replaces_a_hosts_windows() { let mut ui = ScriptUi::default(); diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index a7a85213..ad7a9fb7 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -37,7 +37,13 @@ //! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows //! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for //! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close. -//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. +//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. Resource +//! strings (`new Window("dialog { ok: Button { text: 'OK' } }")`, `add("group { … }")`) build +//! whole trees; `onDraw` handlers paint with `ScriptUIGraphics` (`newPen`/`newBrush`/`rectPath`/ +//! `ellipsePath`/`moveTo`/`lineTo`/`fillPath`/`strokePath`/`drawString`/`measureString`) into a +//! serde draw list frontends paint; edit texts and sliders fire `onChanging` live. +//! * **Sockets** (`socket.rs`): `Socket` (`open`/`listen`/`poll`/`read`/`readln`/`write`/`close`) +//! over TCP, behind the same network preference; on the web `open` returns false. //! //! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which //! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console @@ -48,6 +54,7 @@ pub mod matchnames; mod model; mod runtime; +mod socket; mod ui; use effectcraft_engine::{ScriptRequest, Session}; @@ -75,3 +82,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { mod tests; #[cfg(test)] mod tests_ui; +#[cfg(test)] +mod tests_ui_more; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index fec9d9e3..f265ae39 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -166,7 +166,7 @@ File.prototype = { read: function (n) { if (this.__buf === null) this.open("r"); if (this.__buf === null) return ""; - var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substring(this.__pos, this.__pos + n); this.__pos += s.length; return s; }, @@ -255,7 +255,77 @@ Object.defineProperty(Folder, "temp", { get: function () { return null; } }); Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); -function Socket() { __file("network", ""); } +// TCP sockets (behind Allow Scripts to Write Files and Access Network; see socket.rs). +function Socket() { + if (!(this instanceof Socket)) return new Socket(); + __sock("check", 0, "{}"); + this.__id = 0; + this.host = ""; + this.timeout = 10; + this.encoding = "ASCII"; + this.error = ""; +} +function __sockCall(s, op, extra) { + var p = extra || {}; + p.timeout = s.timeout; + p.encoding = s.encoding; + var r = JSON.parse(__sock(op, s.__id, JSON.stringify(p))); + s.error = r.error || ""; + return r; +} +Socket.prototype = { + constructor: Socket, + get connected() { return this.__id > 0 && __sockCall(this, "connected").value === true; }, + get eof() { return this.__id === 0 || __sockCall(this, "eof").value === true; }, + open: function (host, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "open", { host: String(host) }); + if (r.error) return false; + this.__id = r.id; + this.host = String(host); + return true; + }, + listen: function (port, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "listen", { port: __num(port, "port") }); + if (r.error) return false; + this.__id = r.id; + this.host = "127.0.0.1:" + r.port; + this.port = r.port; + return true; + }, + poll: function () { + if (!this.__id) return null; + var r = __sockCall(this, "poll"); + if (r.id === null || r.id === undefined) return null; + var s = Object.create(Socket.prototype); + s.__id = r.id; + s.host = r.host || ""; + s.timeout = this.timeout; + s.encoding = this.encoding; + s.error = ""; + return s; + }, + read: function (count) { + if (!this.__id) return ""; + return __sockCall(this, "read", { count: count === undefined ? -1 : __num(count, "count") }).data || ""; + }, + readln: function () { return this.__id ? __sockCall(this, "readln").data || "" : ""; }, + write: function () { + if (!this.__id) return false; + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + return !__sockCall(this, "write", { data: s }).error; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + close: function () { + if (this.__id) __sockCall(this, "close"); + this.__id = 0; + return true; + }, + toString: function () { return "[object Socket]"; } +}; var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; // ------------------------------------------------------------------ helpers diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 82e456af..291608c1 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -282,7 +282,7 @@ fn normalize(p: &str) -> std::path::PathBuf { .unwrap_or(out) } -const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; +pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; /// The file-access gate: reads under the project's folder are always allowed; other reads, all /// writes and listing other folders need the Allow Scripts to Write Files preference. @@ -375,13 +375,14 @@ pub(crate) fn new_context() -> Result { let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 9] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), (js_string!("__uiLayoutNative"), 1, crate::ui::native_layout), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index e214be30..57e0f445 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -58,13 +58,205 @@ var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 }; function Window(type, title, bounds, props) { if (!(this instanceof Window)) return new Window(type, title, bounds, props); - type = String(type === undefined ? "window" : type).toLowerCase(); - if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()"); + type = String(type === undefined ? "window" : type); + var res = null; + if (type.indexOf("{") >= 0) { + // A resource string: `dialog { text: 'Title', ok: Button { text: 'OK' } }`. + res = __uiParseRes(type); + type = res.type; + var p0 = __uiResProps(res).props; + if (title === undefined && p0.text !== undefined) title = p0.text; + if (bounds === undefined && p0.bounds !== undefined) bounds = p0.bounds; + if (props === undefined && p0.properties !== undefined) props = p0.properties; + } + type = type.toLowerCase(); if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type); __uiInit(this, "window", null, null, bounds, title, props); this.__kind = type; this.__root = true; __uiWins.push(this); + if (res) __uiApplyRes(this, res); +} + +// ---------------------------------------------------------------- resource strings +// +// The resource specification the JavaScript Tools Guide documents: `type { name: value, … }` +// where a value is a string ('…' or "…"), a number, true / false, an array `[…]`, an object +// literal `{…}` (e.g. `properties: {multiline: true}`) or a nested element `Type { … }` (a +// child control, also reachable as `parent.name`). Comments are allowed. + +function __uiParseRes(src) { + var s = String(src), i = 0; + function fail(m) { throw __err("ScriptUI resource string: " + m + " at offset " + i); } + function ws() { + for (;;) { + while (i < s.length && /\s/.test(s.charAt(i))) i++; + if (s.substring(i, i + 2) === "//") { + while (i < s.length && s.charAt(i) !== "\n") i++; + continue; + } + if (s.substring(i, i + 2) === "/*") { + var e = s.indexOf("*/", i + 2); + i = e < 0 ? s.length : e + 2; + continue; + } + return; + } + } + function ident() { + ws(); + var m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i)); + if (!m) fail("expected a name"); + i += m[0].length; + return m[0]; + } + function str(q) { + i++; + var out = ""; + while (i < s.length && s.charAt(i) !== q) { + var c = s.charAt(i); + if (c === "\\") { + i++; + var e = s.charAt(i); + out += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e; + } else out += c; + i++; + } + if (i >= s.length) fail("unterminated string"); + i++; + return out; + } + function value() { + ws(); + var c = s.charAt(i); + if (c === "'" || c === '"') return str(c); + if (c === "[") { + i++; + var a = []; + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } + for (;;) { + a.push(value()); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "]") { + i++; + return a; + } + fail("expected , or ]"); + } + } + if (c === "{") return { __object: body() }; + var m = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(s.slice(i)); + if (m) { + i += m[0].length; + return Number(m[0]); + } + var id = ident(); + if (id === "true") return true; + if (id === "false") return false; + if (id === "null" || id === "undefined") return null; + ws(); + if (s.charAt(i) === "{") return { __element: id, list: body() }; + // A bare word (an enum-like value). + return id; + } + function body() { + i++; + var list = []; + for (;;) { + ws(); + if (s.charAt(i) === "}") { + i++; + return list; + } + var k = s.charAt(i) === "'" || s.charAt(i) === '"' ? str(s.charAt(i)) : ident(); + ws(); + if (s.charAt(i) !== ":") fail("expected : after " + k); + i++; + list.push([k, value()]); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "}") { + i++; + return list; + } + fail("expected , or }"); + } + } + var t = ident(); + ws(); + if (s.charAt(i) !== "{") fail("expected { after " + t); + var list = body(); + ws(); + if (i < s.length) fail("unexpected text after the resource"); + return { type: t, list: list }; +} + +// The plain JavaScript value of a parsed resource value (object literals become objects). +function __uiResPlain(v) { + if (v instanceof Array) return v.map(__uiResPlain); + if (v && typeof v === "object" && v.__object) { + var o = {}; + for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]); + return o; + } + return v; +} + +// A resource's own properties and its child elements (in source order). +function __uiResProps(res) { + var props = {}, kids = []; + for (var k = 0; k < res.list.length; k++) { + var e = res.list[k]; + if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]); + else props[e[0]] = __uiResPlain(e[1]); + } + return { props: props, kids: kids }; +} + +var __uiResSkip = { text: 1, bounds: 1, properties: 1, minvalue: 1, maxvalue: 1, items: 1 }; +var __uiSizes = { preferredSize: 1, minimumSize: 1, maximumSize: 1, size: 1, location: 1 }; + +// Set a control's properties and create its children from a parsed resource. +function __uiApplyRes(c, res) { + var pk = __uiResProps(res), props = pk.props; + // Ranges before values, items before selections. + if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue); + if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue); + if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]); + for (var k in props) { + if (__uiResSkip[k]) continue; + var v = props[k]; + if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]); + c[k] = v; + } + for (var j = 0; j < pk.kids.length; j++) { + var child = __uiAddRes(c, pk.kids[j][1]); + child.__key = pk.kids[j][0]; + c[pk.kids[j][0]] = child; + } + return c; +} + +// add() with a resource string (or a parsed element): the new control. +function __uiAddRes(parent, spec) { + var res = typeof spec === "string" ? __uiParseRes(spec) : spec; + var p = __uiResProps(res).props; + var t = String(res.type).toLowerCase(); + var valued = t === "dropdownlist" || t === "listbox" || t === "slider" || t === "scrollbar" || t === "progressbar"; + var c = parent.add(t, p.bounds, valued ? undefined : p.text, p.properties); + if (valued && p.text !== undefined) c.text = String(p.text); + return __uiApplyRes(c, res); } Window.alert = function (s) { alert(s); }; Window.confirm = function (s) { return confirm(s); }; @@ -106,18 +298,95 @@ var ScriptUI = { events: { createEvent: function (t) { return { type: t }; } } }; +// A colour for the draw list: [r, g, b, a] in 0..1, or null for a theme colour (by name). +function __uiColor(c) { + if (c instanceof Array && c.length >= 3) return [__num(c[0]), __num(c[1]), __num(c[2]), c.length > 3 ? __num(c[3]) : 1]; + return null; +} +function __uiFontSize(f, g) { + var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12; + return x > 0 ? x : 12; +} + +// ScriptUIGraphics: what onDraw handlers paint with. Paths and paint calls are recorded into the +// control's draw list (`draw` in the window snapshot), which frontends paint. function __uiGraphics() { return { - newBrush: function (t, c) { return { type: t, color: c }; }, - newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; }, - newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {}, - ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {}, - drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {}, - measureString: function (s) { return __dim(String(s).length * 7, 16); }, - font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null + BrushType: ScriptUI.BrushType, + PenType: ScriptUI.PenType, + font: ScriptUI.newFont("dialog", "REGULAR", 12), + foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null, + currentPath: [], + currentPoint: [0, 0], + __rec: null, + __push: function (op) { if (this.__rec) this.__rec.push(op); }, + newBrush: function (t, c) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "" }; }, + newPen: function (t, c, w) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "", lineWidth: w === undefined ? 1 : __num(w) }; }, + newPath: function () { this.currentPath = []; return this.currentPath; }, + moveTo: function (x, y) { this.currentPath.push({ k: "M", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + lineTo: function (x, y) { this.currentPath.push({ k: "L", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + closePath: function () { this.currentPath.push({ k: "Z" }); }, + rectPath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "R", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + ellipsePath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "E", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + fillPath: function (brush, path) { + this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() }); + }, + strokePath: function (pen, path) { + this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() }); + }, + drawString: function (text, pen, x, y, font) { + var f = font || this.font; + this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") }); + }, + drawOSControl: function () { this.__push({ op: "os" }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, + drawFocusRing: function () {}, + measureString: function (s, font, boundaryWidth) { + var size = __uiFontSize(font, this); + var lines = String(s).split("\n"); + var cw = size * 0.58, w = 0; + for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw)); + var n = lines.length; + if (boundaryWidth > 0 && w > boundaryWidth) { + n = Math.ceil(w / boundaryWidth) + lines.length - 1; + w = boundaryWidth; + } + return __dim(w, Math.ceil(size * 1.25) * n); + } }; } +// Run the onDraw handlers of a window's controls (after laying it out, so `size` is known), +// recording what they paint. +function __uiRunDraws(win) { + var drawn = []; + (function walk(c) { + if (typeof c.onDraw === "function") drawn.push(c); + for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i]); + })(win); + if (!drawn.length) return; + __uiLayout(win); + for (var k = 0; k < drawn.length; k++) { + var c = drawn[k]; + c.__draw = []; + c.graphics.__rec = c.__draw; + try { + c.onDraw.call(c, { type: "draw", target: c }); + } catch (e) { + try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {} + } + c.graphics.__rec = null; + } +} + function __uiInit(o, type, win, parent, bounds, text, props) { o.type = type; o.window = win || o; @@ -180,6 +449,10 @@ function __uiProto() { return { add: function (type, bounds, text, props) { var t = String(type).toLowerCase(); + if (t.indexOf("{") >= 0) { + if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); + return __uiAddRes(this, String(type)); + } if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds); if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); var ctor = __uiTypes[t]; @@ -302,7 +575,7 @@ function __uiProto() { }, __handlers: function () { var out = []; - var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"]; + var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"]; for (var i = 0; i < names.length; i++) { var n = names[i]; if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n); @@ -411,7 +684,7 @@ function __uiJson(c) { var o = { id: c.__id, type: c.type, - name: String(c.name || c.properties.name || ""), + name: String(c.name || c.properties.name || c.__key || ""), text: String(c.text === undefined || c.text === null ? "" : c.text), enabled: c.enabled !== false, visible: c.visible !== false, @@ -444,6 +717,7 @@ function __uiJson(c) { o.selection = c.__sel.slice(); } if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; + if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw; if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; @@ -456,6 +730,7 @@ function __uiSnapshot() { var out = []; for (var i = 0; i < __uiWins.length; i++) { var w = __uiWins[i]; + if (w.__shown && !w.__closed) __uiRunDraws(w); out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) }); } // Closed windows are forgotten. @@ -529,14 +804,17 @@ function __uiDispatch(winId, id, kind, valueJson) { } return; } - // change / changing + // change / changing (live: each keystroke of an edit text, each step of a slider drag) + var before = c.type === "edittext" ? c.text : c.value; if (c.type === "edittext") c.text = v === null ? "" : String(v); else if (c.type === "statictext") c.text = v === null ? "" : String(v); else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v); else if (c.type === "checkbox") c.value = !!v; else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; } else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v; - if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging"); + var now = c.type === "edittext" ? c.text : c.value; + // onChanging already ran for live updates of the same value. + if ((c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") && (kind === "changing" || now !== before)) c.__fire("onChanging"); if (kind === "change") { if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick"); else c.__fire("onChange"); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs new file mode 100644 index 00000000..8b1074d4 --- /dev/null +++ b/crates/script/src/socket.rs @@ -0,0 +1,241 @@ +//! `Socket`: TCP connections for scripts (the JavaScript Tools Guide's Socket object: `open`, +//! `listen`, `poll`, `read`, `readln`, `write`, `writeln`, `close`, `connected`, `eof`). +//! +//! Network access follows Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files +//! and Access Network: with it off, `new Socket()` throws. On the web there are no raw sockets: +//! `open` and `listen` return false (with `error` set). +//! +//! The JavaScript side holds only a handle; the streams live here, keyed by handle, so a script +//! window's handlers can keep using a socket opened earlier in the same script. + +use boa_engine::{Context, JsResult, JsValue}; +use serde_json::{Value as J, json}; + +use crate::runtime::{arg_string, js_str, throw, with_active}; + +/// `__sock(op, handle, paramsJson)` → result JSON (`{error}` on failure). +pub(crate) fn native_sock(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; + let params = arg_string(args, 2, ctx)?; + let p: J = serde_json::from_str(¶ms).unwrap_or(J::Null); + let allowed = with_active(|a| Ok(a.session.prefs.scripting.allow_scripts_write_files)).unwrap_or(false); + if !allowed { + return Err(throw(format!("scripts can't access the network unless {} is on", crate::runtime::GATE))); + } + if op == "check" { + return Ok(js_str("{}")); + } + let r = imp::run(&op, id, &p); + Ok(js_str(&r.to_string())) +} + +fn err(e: impl std::fmt::Display) -> J { + json!({"error": e.to_string()}) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + pub(super) fn run(op: &str, _: u32, _: &J) -> J { + match op { + "connected" | "eof" => json!({"value": op == "eof"}), + "close" => json!({}), + _ => err("sockets are not available in the web version of EffectCraft"), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::collections::BTreeMap; + use std::io::{ErrorKind, Read, Write}; + use std::net::{TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use super::*; + + enum Sock { + Stream { s: TcpStream, buf: Vec, eof: bool }, + Listener(TcpListener), + } + + struct Table { + next: u32, + socks: BTreeMap, + } + + fn table() -> &'static Mutex { + static T: Mutex
= Mutex::new(Table { next: 1, socks: BTreeMap::new() }); + &T + } + + fn add(s: Sock) -> u32 { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + let id = t.next; + t.next += 1; + t.socks.insert(id, s); + id + } + + fn timeout(p: &J) -> Duration { + Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) + } + + /// `host:port` (After Effects' form) → socket addresses. + fn addr(host: &str) -> Result, String> { + let a = host.to_socket_addrs().map_err(|e| format!("bad address `{host}` (use host:port): {e}"))?; + Ok(a.collect()) + } + + /// Text ↔ bytes in the socket's encoding: UTF-8, else one byte per character (ASCII / + /// BINARY, as in After Effects). + fn encode(s: &str, utf8: bool) -> Vec { + if utf8 { s.as_bytes().to_vec() } else { s.chars().map(|c| (c as u32).min(255) as u8).collect() } + } + fn decode(b: &[u8], utf8: bool) -> String { + if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } + } + + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + let end = Instant::now() + limit; + let mut chunk = [0u8; 4096]; + while !done(buf) && !*eof { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + match s.read(&mut chunk) { + Ok(0) => *eof = true, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => { + if e.kind() != ErrorKind::Interrupted { + break; + } + } + Err(_) => *eof = true, + } + } + } + + pub(super) fn run(op: &str, id: u32, p: &J) -> J { + let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); + match op { + "open" => { + let host = p.get("host").and_then(J::as_str).unwrap_or(""); + let addrs = match addr(host) { + Ok(a) => a, + Err(e) => return err(e), + }; + let limit = timeout(p).max(Duration::from_millis(10)); + let mut last = "no address".to_string(); + for a in addrs { + match TcpStream::connect_timeout(&a, limit) { + Ok(s) => { + let _ = s.set_nodelay(true); + return json!({"id": add(Sock::Stream { s, buf: vec![], eof: false })}); + } + Err(e) => last = e.to_string(), + } + } + err(format!("cannot connect to {host}: {last}")) + } + "listen" => { + let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { + Ok(l) => { + let port = l.local_addr().map(|a| a.port()).unwrap_or(port); + json!({"id": add(Sock::Listener(l)), "port": port}) + } + Err(e) => err(format!("cannot listen on port {port}: {e}")), + } + } + _ => { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if op == "close" { + t.socks.remove(&id); + return json!({}); + } + let Some(sock) = t.socks.get_mut(&id) else { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is not open"), + }; + }; + match (op, sock) { + ("poll", Sock::Listener(l)) => match l.accept() { + Ok((s, peer)) => { + let _ = s.set_nonblocking(false); + let host = peer.to_string(); + let new = Sock::Stream { s, buf: vec![], eof: false }; + let nid = t.next; + t.next += 1; + t.socks.insert(nid, new); + json!({"id": nid, "host": host}) + } + Err(_) => json!({"id": null}), + }, + ("connected", Sock::Stream { s, buf, eof }) => { + if !*eof && buf.is_empty() { + // Peek without blocking to notice a closed peer. + let _ = s.set_nonblocking(true); + let mut b = [0u8; 1]; + match s.peek(&mut b) { + Ok(0) => *eof = true, + Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, + _ => {} + } + let _ = s.set_nonblocking(false); + } + json!({"value": !(*eof && buf.is_empty())}) + } + ("connected", Sock::Listener(_)) => json!({"value": true}), + ("eof", Sock::Stream { buf, eof, .. }) => json!({"value": *eof && buf.is_empty()}), + ("read", Sock::Stream { s, buf, eof }) => { + let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); + let limit = timeout(p); + if count < 0 { + fill(s, buf, eof, limit, |_| false); + let all = std::mem::take(buf); + json!({"data": decode(&all, utf8)}) + } else { + let n = count as usize; + fill(s, buf, eof, limit, |b| b.len() >= n); + let take = n.min(buf.len()); + let out: Vec = buf.drain(..take).collect(); + json!({"data": decode(&out, utf8)}) + } + } + ("readln", Sock::Stream { s, buf, eof }) => { + fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + let line: Vec = match buf.iter().position(|&c| c == b'\n') { + Some(i) => { + let mut l: Vec = buf.drain(..=i).collect(); + l.pop(); + if l.last() == Some(&b'\r') { + l.pop(); + } + l + } + None => std::mem::take(buf), + }; + json!({"data": decode(&line, utf8)}) + } + ("write", Sock::Stream { s, .. }) => { + let data = p.get("data").and_then(J::as_str).unwrap_or(""); + match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + Ok(()) => json!({"ok": true}), + Err(e) => err(e), + } + } + ("host", Sock::Stream { s, .. }) => json!({"value": s.peer_addr().map(|a| a.to_string()).unwrap_or_default()}), + (op, _) => err(format!("`{op}` is not supported on this socket")), + } + } + } + } +} diff --git a/crates/script/src/tests_ui.rs b/crates/script/src/tests_ui.rs index 667a3242..460376ac 100644 --- a/crates/script/src/tests_ui.rs +++ b/crates/script/src/tests_ui.rs @@ -183,9 +183,9 @@ fn layout_and_handler_errors() { assert_eq!(r["ok"], false); assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}"); assert_eq!(s.script_ui.windows.len(), 1); - // Resource strings aren't supported (a clear error). - let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx"); - assert!(o.error.unwrap().message.contains("resource")); + // Resource strings build windows too (see tests_ui_more). + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx"); + assert_eq!(o.result, json!("x")); s.execute("scriptui.close", json!({})).unwrap(); } diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs new file mode 100644 index 00000000..29d92840 --- /dev/null +++ b/crates/script/src/tests_ui_more.rs @@ -0,0 +1,173 @@ +//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind}; +use serde_json::json; + +use crate::run_code; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +const RESOURCE: &str = r#" +var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \ + g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \ + nm: EditText { text: 'abc', characters: 10 }, \ + s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \ + dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \ + /* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }"); +var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }"); +w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); }; +w.show(); +[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|") +"#; + +#[test] +fn resource_strings_build_windows_and_controls() { + let mut s = session(); + let o = run_code(&mut s, RESOURCE, "res.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200")); + let w = s.script_ui.windows[0].clone(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res")); + let kinds: Vec = w.root.children.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]); + assert_eq!(w.root.children[2].value, 4.0); + assert_eq!(w.root.children[3].items, ["A", "B"]); + assert_eq!(w.root.children[3].selection, [1]); + assert!(w.root.children[4].children[0].checked); + assert_eq!(w.root.children[4].children[1].children[0].text, "hi"); + // Elements are named after their resource keys (agents address them by it). + assert_eq!(w.root.children[1].name, "nm"); + s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap(); + let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap(); + assert_eq!(r["output"], "ok xyz 4", "{r}"); + s.execute("scriptui.close", json!({})).unwrap(); + // Syntax errors say where. + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("resource string")); + let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("Bad window type")); +} + +const DRAW: &str = r#" +var w = new Window("palette", "Draw"); +var c = w.add("group"); +c.preferredSize = [100, 50]; +c.onDraw = function () { + var g = this.graphics; + var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]); + g.newPath(); + g.rectPath(0, 0, this.size.width, this.size.height); + g.fillPath(b); + var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2); + g.newPath(); + g.moveTo(0, 0); + g.lineTo(10, 10); + g.strokePath(p); + g.ellipsePath(20, 20, 10, 10); + g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20)); +}; +var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12)); +w.show(); +[m.width, m[1]].join(",") +"#; + +#[test] +fn on_draw_handlers_record_a_draw_list() { + let mut s = session(); + let o = run_code(&mut s, DRAW, "draw.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("35,15")); + let w = s.script_ui.windows[0].clone(); + let g = &w.root.children[0]; + assert!(g.handlers.contains(&"onDraw".to_string())); + assert_eq!(g.draw.len(), 3, "{:?}", g.draw); + // The handler saw the laid-out size. + assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] }); + match &g.draw[1] { + DrawOp::Stroke { color, width, path } => { + assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0)); + assert_eq!(path.len(), 2); + } + o => panic!("{o:?}"), + } + assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD")); + // scriptui.get carries it for agents. + let tree = s.execute("scriptui.get", json!({})).unwrap(); + assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const LIVE: &str = r#" +var w = new Window("palette", "Live"); +var e = w.add("edittext", undefined, ""); +var sl = w.add("slider", undefined, 0, 0, 100); +var log = w.add("statictext", undefined, "-"); +e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; }; +e.onChange = function () { writeLn("change " + e.text); }; +sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); }; +sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); }; +w.show(); +"#; + +#[test] +fn on_changing_fires_per_keystroke_and_slider_step() { + let mut s = session(); + let o = run_code(&mut s, LIVE, "live.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing a"); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing ab"); + assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab"); + // Committing the same text runs onChange only. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap(); + assert_eq!(r["output"], "change ab"); + // A committed edit without live updates runs both. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap(); + assert_eq!(r["output"], "changing abc\nchange abc"); + // Slider drags update the value live. + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap(); + assert_eq!(r["output"], "slide 40"); + assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0); + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap(); + assert_eq!(r["output"], "slide 41\nslid 41"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +#[test] +fn sockets_talk_tcp_behind_the_network_preference() { + use std::io::{BufRead, BufReader, Write}; + let mut s = session(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut c, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap(); + c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap(); + }); + let code = format!( + "var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')" + ); + // Off: no network. + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.unwrap().message.contains("Access Network")); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("true|pong:ping|rest|true|false")); + server.join().unwrap(); + // Refused connections return false with an error; listen + poll serve clients. + let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx"); + assert_eq!(o.result, json!("false|true")); + let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); + assert_eq!(o.result, json!("true|true")); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 580e287b..ddf3d35e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -3,9 +3,10 @@ //! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` / //! `scriptui.close` commands, which run the script's handlers. Every control registers an //! automation id `scriptui..` (and `scriptui..` when the script -//! named it). +//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and +//! sliders send live `changing` updates (onChanging) while typing / dragging. -use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout}; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ use crate::theme::Tokens; enum Act { Click(u32, u32), Set(u32, u32, Value), + /// A live update (each keystroke / slider step): fires onChanging. + Changing(u32, u32, Value), Close(u32), } @@ -78,6 +81,27 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, register(app, win, w, rect); let id = egui::Id::new(("sui", win, w.id)); let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) }; + // onDraw: the script paints the control. Without drawOSControl() its paint replaces the + // default look (buttons and toggles still respond to clicks); with it, it goes on top. + let custom = w.handlers.iter().any(|h| h == "onDraw"); + let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os)); + if custom && !os { + if w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + draw_children(app, ui, origin, win, w, acts); + return; + } + let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton); + let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() }); + paint_draw(ui, rect, &w.draw, &t); + if tip(r).clicked() && clickable { + acts.push(Act::Click(win, w.id)); + } + return; + } + if custom && w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), WidgetKind::Panel => { @@ -112,8 +136,16 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0)); let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner; let r = tip(r); - if r.lost_focus() && buf != w.text { + // Live updates already sent the text: the commit still runs onChange. + let dirty_id = id.with("dirty"); + let dirty = ui.data(|d| d.get_temp::(dirty_id)).unwrap_or(false); + if r.lost_focus() && (buf != w.text || dirty) { acts.push(Act::Set(win, w.id, json!(buf))); + ui.data_mut(|d| d.remove::(dirty_id)); + } else if r.changed() && w.handlers.iter().any(|h| h == "onChanging") { + // Each keystroke reaches the script's onChanging (onChange runs on commit). + acts.push(Act::Changing(win, w.id, json!(buf))); + ui.data_mut(|d| d.insert_temp(dirty_id, true)); } ui.data_mut(|d| d.insert_temp(id, buf)); } @@ -145,6 +177,10 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, .inner; let r = tip(r); if r.dragged() { + // Live: each step of the drag reaches the script (onChanging). + if r.changed() { + acts.push(Act::Changing(win, w.id, json!(v))); + } ui.data_mut(|d| d.insert_temp(id, v)); } else { ui.data_mut(|d| d.remove::(id)); @@ -203,6 +239,45 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } } + if custom && !w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } +} + +/// Paint an onDraw draw list in `rect` (control coordinates → screen). +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { + let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + let col = |c: &Option<[f32; 4]>, theme: Color32| match c { + Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), + None => theme, + }; + let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); + for op in ops { + match op { + DrawOp::Fill { color, path } => { + let c = col(color, t.field_bg); + for (pts, _) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE)); + } + } + DrawOp::Stroke { color, width, path } => { + let s = Stroke::new(*width as f32, col(color, t.text)); + for (pts, closed) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); + } + } + DrawOp::Text { text, color, x, y, size, .. } => { + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + } + DrawOp::Image { x, y, w, h } => { + let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + DrawOp::Os => {} + } + } } /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. @@ -216,6 +291,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { let (cmd, p) = match a { Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})), Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), + Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), }; match crate::menus::invoke(app, ctx, cmd, p) { From 558de717205c8e83afe0a9b96ac35447a74a20c4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:23:22 -0400 Subject: [PATCH 2/4] M13.7: Essential Graphics scripting hooks, Font and uniform Scale controls, Open in Essential Graphics Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/essential.rs | 145 +++++++++++++++++++--- crates/engine/src/lib.rs | 2 + crates/engine/src/menus.rs | 1 + crates/engine/src/tests_essential_more.rs | 94 ++++++++++++++ crates/project/src/essential.rs | 80 +++++++++++- crates/script/src/prelude.js | 57 ++++++++- crates/script/src/tests_ui_more.rs | 43 +++++++ crates/ui-egui/src/panels/essential.rs | 66 +++++++++- 8 files changed, 462 insertions(+), 26 deletions(-) create mode 100644 crates/engine/src/tests_essential_more.rs diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index a24fcdc0..d7340f68 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,6 +88,14 @@ fn add_property(s: &mut Session, p: &Value) -> Result { let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let rename = str_p(p, "name").map(str::to_string); + // `as`: show the property as another control (Source Text as `font`, Scale as `scale`). + let as_type = match str_p(p, "as") { + None => None, + Some(a) => { + let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?; + Some(t) + } + }; let mut added = vec![]; for (cid, lid, uid) in &targets { let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?; @@ -95,20 +103,32 @@ fn add_property(s: &mut Session, p: &Value) -> Result { if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } - if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) { + // The natural control needs no `as`. + let as_type = as_type.filter(|t| control_type(pr) != Some(*t)); + if let Some(t) = as_type + && !essential::can_show_as(pr, t) + { + return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label()))); + } + if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for_as(*lid, *uid, as_type).is_some()) { continue; } - added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + let name = rename.clone().unwrap_or_else(|| match as_type { + Some(ControlType::Font) => format!("{} Font", l.name), + _ => pr.name.clone(), + }); + added.push((*cid, *lid, *uid, name, as_type)); } let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| { if let Some((cid, ..)) = added.first() { st.essential_primary.get_or_insert(*cid); } let mut ids = vec![]; - for (k, (cid, lid, uid, name)) in added.iter().enumerate() { + for (k, (cid, lid, uid, name, as_type)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; - insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?; + let ctl = EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid }, as_type: *as_type }; + insert(eg, ctl, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) @@ -120,6 +140,39 @@ fn control_type(pr: &effectcraft_project::Property) -> Option { essential::control_type(pr) } +/// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family, +/// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is +/// not text. +pub fn font_value(cur: &KV, v: &Value) -> Option { + let KV::Text(doc) = cur else { return None }; + let mut d = (**doc).clone(); + match v { + Value::String(f) => d.font = f.clone(), + Value::Object(o) => { + if let Some(f) = o.get("font").and_then(Value::as_str) { + d.font = f.to_string(); + } + if let Some(st) = o.get("style").and_then(Value::as_str) { + d.style = st.to_string(); + } + if let Some(z) = o.get("size").and_then(Value::as_f64) { + d.size = z.max(0.1); + } + } + _ => return None, + } + serde_json::to_value(&d).ok() +} + +/// A control's value for agents: Font controls show `{font, style, size}`, the rest the +/// property's value. +fn value_json(ty: Option, v: &KV) -> Value { + match (ty, v) { + (Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}), + _ => v.to_json(), + } +} + /// Media Replacement for a footage layer. fn add_media(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMedia"; @@ -137,7 +190,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result { if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) { return Err(bad(C, "this layer already has a Media Replacement control")); } - insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?; + insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?; Ok(id) })?; Ok(json!({"control": id})) @@ -150,7 +203,7 @@ fn add_group(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Group", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?; + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?; Ok(id) })?; Ok(json!({"control": id})) @@ -163,7 +216,13 @@ fn add_comment(s: &mut Session, p: &Value) -> Result { let id = s.edit("Add Essential Graphics Comment", None, |proj, st| { st.essential_primary.get_or_insert(cid); let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?; + insert( + eg_of(proj, cid)?, + EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None }, + group, + None, + "essential.addComment", + )?; Ok(id) })?; Ok(json!({"control": id})) @@ -262,6 +321,35 @@ fn edit_dropdown(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "items": items})) } +/// Composition ▸ Open in Essential Graphics. +fn open_in_eg(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.state.essential_primary = Some(cid); + s.bump(); + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) }); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + Ok(json!({"comp": cid.0, "name": name})) +} + +/// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`). +fn can_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.canAdd"; + let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(uid).ok_or_else(|| bad(C, "no such property"))?; + let as_type = str_p(p, "as").and_then(ControlType::parse); + let ty = match as_type { + Some(t) if essential::can_show_as(pr, t) => Some(t), + Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})), + None => control_type(pr), + }; + let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP)); + Ok(match ty { + Some(t) if !in_eg => json!({"ok": true, "type": t}), + _ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}), + }) +} + fn solo_supported(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "on").unwrap_or(!s.state.essential_solo); s.state.essential_solo = on; @@ -299,8 +387,8 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop, "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)), - "type": src.and_then(|(_, pr)| essential::control_type(pr)), - "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "type": src.and_then(|(_, pr)| essential::effective_type(c, pr)), + "value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), }) } @@ -384,8 +472,8 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { "control": c.id, "name": c.name, "kind": kind, "uid": pr.as_ref().map(|x| x.uid), "path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)), - "value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()), - "type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)), + "value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))), + "type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)), "overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)), })); } @@ -395,7 +483,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { /// Set an instance's value for a control (`value`, or `item` for Media Replacement). fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; - let (cid, lid, _) = instance(s, p, C)?; + let (cid, lid, src) = instance(s, p, C)?; let control = control_p(p, C)?; let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?; if let Some(item) = p.get("item") { @@ -414,7 +502,13 @@ fn set_override(s: &mut Session, p: &Value) -> Result { })?; return Ok(json!({"control": control, "item": item.0})); } - let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?}); + let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?; + let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type); + if as_type == Some(ControlType::Font) { + let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0)); + value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?; + } + let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value}); if let Some(m) = p.get("merge") { q["merge"] = m.clone(); } @@ -477,12 +571,21 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { let mut writes = vec![]; for (control, uid) in &ts { let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue }; - writes.push((ctl.kind.clone(), pr.clone())); + writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type)); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; - for (kind, pr) in &writes { + for (kind, pr, as_type) in &writes { match kind { + // A Font control writes only the font (the source keeps its text and keys). + EgKind::Property { layer, prop } if *as_type == Some(ControlType::Font) => { + if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) { + essential::merge_font(&mut target.value, &pr.value); + for k in &mut target.keys { + essential::merge_font(&mut k.value, &pr.value); + } + } + } EgKind::Property { layer, prop } => { let Some(inner) = sc.layer_mut(*layer) else { continue }; // Instance keys are in the precomp layer's time (= the source comp's time); @@ -740,7 +843,7 @@ pub fn specs() -> Vec { "Add Property to Essential Graphics", ["Animation"], None, - "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}", + "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}", has_selected_props, add_property ), @@ -774,6 +877,16 @@ pub fn specs() -> Vec { cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert), cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown), query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list), + query!("essential.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add), + cmd!( + "comp.openInEssentialGraphics", + "Open in Essential Graphics", + ["Composition"], + None, + "{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel", + has_comp, + open_in_eg + ), query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json), query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info), ] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 758c1dde..c67b00be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -780,6 +780,8 @@ mod tests_effects; #[cfg(test)] mod tests_essential; #[cfg(test)] +mod tests_essential_more; +#[cfg(test)] mod tests_face; #[cfg(test)] mod tests_fidelity; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index d43b9bcc..8bb8395b 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -666,6 +666,7 @@ Composition Pre-render... | render.preRender Save Current Preview... | render.saveCurrentPreview --- + Open in Essential Graphics | comp.openInEssentialGraphics Responsive Design — Time Create Intro | comp.responsiveTime {"op":"intro"} Create Outro | comp.responsiveTime {"op":"outro"} diff --git a/crates/engine/src/tests_essential_more.rs b/crates/engine/src/tests_essential_more.rs new file mode 100644 index 00000000..314f7b6d --- /dev/null +++ b/crates/engine/src/tests_essential_more.rs @@ -0,0 +1,94 @@ +//! Essential Graphics Font and uniform Scale controls, Composition ▸ Open in Essential Graphics. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::essential::{self, ControlType}; +use serde_json::json; + +use crate::{Event, Session}; + +#[test] +fn font_and_scale_controls_override_per_instance() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + // Source Text: as text and as font; Scale as one slider. + let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap(); + // Adding the same font control again is a no-op; opacity can't be a font. + assert!( + s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"].as_array().unwrap().is_empty() + ); + assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err()); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale"); + let l = s.execute("essential.list", json!({})).unwrap(); + let types: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["text", "font", "scale"]); + assert!(l["controls"][1]["value"]["size"].as_f64().unwrap() > 0.0); + let eg = s.project.comp(title).unwrap().essential.clone().unwrap(); + assert_eq!(eg.find(f).unwrap().as_type, Some(ControlType::Font)); + // Survives save / load (serde). + let j = serde_json::to_value(&eg).unwrap(); + assert_eq!(serde_json::from_value::(j).unwrap(), eg); + + // An instance: change the text, then the font and the scale. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [inst]})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": t, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": f, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": sc, "value": 50})).unwrap(); + let i = s.execute("essential.instance", json!({"layer": inst})).unwrap(); + assert_eq!(i["controls"][1]["value"]["font"], "Serif Test"); + assert_eq!(i["controls"][1]["value"]["size"], 40.0); + assert_eq!(i["controls"][1]["overridden"], true); + assert_eq!(i["controls"][2]["type"], "scale"); + assert_eq!(i["controls"][2]["value"][0], 50.0); + assert_eq!(i["controls"][2]["value"][1], 50.0); + // What the instance renders: the overridden text with the overridden font, scaled. + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(inst)).unwrap().clone(); + let over: Vec = { + let g = essential::group(&layer).unwrap(); + let mut v = vec![]; + g.walk("", &mut |_, p| { + if let Some(c) = essential::control_of(&p.match_id) { + v.push(essential::Override { control: c, value: p.value.clone() }); + } + }); + v + }; + let p = essential::with_overrides(&s.project, title, &over).unwrap(); + let src = p.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match src.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } + // Push to Comp with only the font: the source keeps its own text. + s.execute("essential.revert", json!({"layer": inst, "control": t})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": inst, "control": f})).unwrap(); + let srcl = s.project.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match srcl.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str()), ("Hello", "Serif Test")), + o => panic!("{o:?}"), + } +} + +#[test] +fn open_in_essential_graphics_sets_the_primary_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 1})).unwrap(); + let a = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.drain_events(); + let r = s.execute("comp.openInEssentialGraphics", json!({"comp": a.0})).unwrap(); + assert_eq!(r["name"], "A"); + assert_eq!(s.state.essential_primary, Some(a)); + assert!( + s.drain_events() + .iter() + .any(|e| matches!(e, Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "essentialGraphics")) + ); + assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); +} diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 885c5062..71a38a0c 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -3,7 +3,8 @@ //! Effects' Master Properties). //! //! - A comp's [`EssentialGraphics`] lists controls: properties of its layers (shown as text, -//! colour, slider, checkbox, point, angle or dropdown controls), Media Replacement slots for +//! colour, slider, checkbox, point, angle or dropdown controls; Source Text also as a font +//! control and Scale as one uniform slider), Media Replacement slots for //! footage layers, comments and groups. //! - Every layer that nests such a comp gets an **Essential Properties** group (match id //! `essential`, [`GroupKind::Essential`]) mirroring the controls, one child per control with @@ -36,6 +37,11 @@ pub struct EgControl { /// Display name (renamable; defaults to the property's name). pub name: String, pub kind: EgKind, + /// The control a property is shown as when not its natural one ([`control_type`]): a + /// Source Text property as a [`ControlType::Font`] control, a Scale as a uniform + /// [`ControlType::Scale`] slider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_type: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -66,11 +72,34 @@ pub enum ControlType { Angle, Dropdown, Media, + /// The font family, style and size of a Source Text property (the text itself stays). + Font, + /// One uniform percentage for a Scale property (every axis the same). + Scale, } impl ControlType { + /// By label or serde name, case-insensitively (`"font"`, `"Scale"`…). + pub fn parse(s: &str) -> Option { + [ + ControlType::Text, + ControlType::Color, + ControlType::Slider, + ControlType::Checkbox, + ControlType::Point, + ControlType::Angle, + ControlType::Dropdown, + ControlType::Media, + ControlType::Font, + ControlType::Scale, + ] + .into_iter() + .find(|t| t.label().eq_ignore_ascii_case(s.trim())) + } pub fn label(self) -> &'static str { match self { + ControlType::Font => "Font", + ControlType::Scale => "Scale", ControlType::Text => "Text", ControlType::Color => "Color", ControlType::Slider => "Slider", @@ -101,6 +130,35 @@ pub fn control_type(p: &Property) -> Option { }) } +/// Whether property `p` can be shown as a `ty` control: its natural control, a Source Text +/// property as Font, a two- or three-dimensional property as uniform Scale. +pub fn can_show_as(p: &Property, ty: ControlType) -> bool { + if matches!(p.ui, ParamUi::Hidden) { + return false; + } + match ty { + ControlType::Font => matches!(p.value, Value::Text(_)), + ControlType::Scale => matches!(p.value, Value::Vec2(_) | Value::Vec3(_)) && !matches!(p.ui, ParamUi::Angle), + ControlType::Media => false, + t => control_type(p) == Some(t), + } +} + +/// The control `c` shows for its property `p` (its `as_type`, else the natural one). +pub fn effective_type(c: &EgControl, p: &Property) -> Option { + c.as_type.filter(|t| can_show_as(p, *t)).or_else(|| control_type(p)) +} + +/// Apply a Font control's value: the font family, style and size of `from` onto `to` (the text, +/// colours and every other attribute stay). +pub fn merge_font(to: &mut Value, from: &Value) { + if let (Value::Text(t), Value::Text(f)) = (to, from) { + t.font = f.font.clone(); + t.style = f.style.clone(); + t.size = f.size; + } +} + impl EssentialGraphics { /// Every control, depth first (groups before their children). pub fn flat(&self) -> Vec<&EgControl> { @@ -159,6 +217,11 @@ impl EssentialGraphics { pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) } + /// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural + /// control): a property can have one control of each kind (Source Text as Text and Font). + pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option) -> Option<&EgControl> { + self.flat().into_iter().find(|c| c.as_type == as_type && matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) + } /// Controls an instance can override (properties and media; not comments or groups). pub fn has_instance_controls(&self) -> bool { self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. })) @@ -385,9 +448,22 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let mut p = project.clone(); let c = p.comp_mut(comp)?; let mut any = false; - for o in overrides { + // Whole values first, then Font controls (which change only the font of whatever text the + // instance shows). + let is_font = |o: &&Override| eg.find(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font)); + let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font)); + for o in ordered { let Some(ctl) = eg.find(o.control) else { continue }; match ctl.kind { + EgKind::Property { layer, prop } if ctl.as_type == Some(ControlType::Font) => { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + merge_font(&mut pr.value, &o.value); + for k in &mut pr.keys { + merge_font(&mut k.value, &o.value); + } + any = true; + } + } EgKind::Property { layer, prop } => { if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { pr.keys.clear(); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f265ae39..897beeaa 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -715,6 +715,19 @@ Property.prototype.property = function () { return null; }; }); def("isDropdownEffect", function () { return false; }); + // Essential Graphics: expose this property in `comp` (its own composition). + P.canAddToMotionGraphicsTemplate = function (comp) { + if (!comp || comp.__id !== this.__layer.__comp) return false; + return __call("essential.canAdd", this.__ref()).ok === true; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined && name !== null) p.name = String(name); + __call("essential.addProperty", p); + return true; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; P.getSeparationFollower = function (dim) { return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); }; @@ -875,8 +888,6 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - P.addToMotionGraphicsTemplate = function () { return false; }; - P.canAddToMotionGraphicsTemplate = function () { return false; }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1422,7 +1433,47 @@ CompItem.prototype.constructor = CompItem; }; P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; - P.exportAsMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics (templates are EffectCraft's open .ectemplate files). + P.__eg = function () { return __call("essential.list", { comp: this.__id }); }; + P.__controllers = function () { + var out = []; + (function walk(list) { + for (var i = 0; i < list.length; i++) { + if (list[i].kind === "property" || list[i].kind === "media") out.push(list[i]); + if (list[i].children) walk(list[i].children); + } + })(this.__eg().controls); + return out; + }; + def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) { + __call("essential.setName", { comp: this.__id, name: String(v) }); + }); + def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; }); + P.getMotionGraphicsTemplateControllerName = function (i) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + return c[i - 1].name; + }; + P.setMotionGraphicsControllerName = function (i, name) { + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + __call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) }); + return String(name); + }; + P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; + P.exportAsMotionGraphicsTemplate = function (overwrite, path) { + var f = __str(path); + if (!f) return false; + if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; + if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; + if (this.__controllers().length === 0) return false; + // The file gate applies to templates too. + __file("write", f, ""); + __call("essential.exportTemplate", { comp: this.__id, path: f }); + return true; + }; })(); function FootageItem(id) { diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 29d92840..153533c4 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -142,6 +142,49 @@ fn on_changing_fires_per_keystroke_and_slider_step() { s.execute("scriptui.close", json!({})).unwrap(); } +const MOGRT: &str = r#" +var c = app.project.activeItem; +var t = c.layer(1); +var st = t.property("ADBE Text Properties").property("ADBE Text Document"); +var op = t.property("ADBE Transform Group").property("ADBE Opacity"); +var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")]; +c.motionGraphicsTemplateName = "Lower Third"; +c.setMotionGraphicsControllerName(1, "Title"); +r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|") +"#; + +#[test] +fn essential_graphics_scripting_hooks() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + let o = run_code(&mut s, MOGRT, "mogrt.jsx"); + assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output); + assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade")); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third"); + // Another comp can't take this comp's properties. + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap(); + let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("false|true"), "{:?}", o.error); + // Export (behind the file-write preference). + let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("lower.ectemplate"); + let export = format!( + "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})", + path.to_string_lossy() + ); + assert!(run_code(&mut s, &export, "x.jsx").error.is_some()); + s.prefs.scripting.allow_scripts_write_files = true; + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true)); + assert!(std::fs::metadata(&path).unwrap().len() > 100); + // It exists now: no overwrite → false. + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn sockets_talk_tcp_behind_the_network_preference() { use std::io::{BufRead, BufReader, Write}; diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index f4c565c5..b22263b9 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -4,7 +4,8 @@ //! be exposed) and the template name. //! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add //! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point / -//! angle / dropdown control editing the source property; rename by double-clicking the name; +//! angle / dropdown control editing the source property (Add Selected As: Source Text as a font +//! control, Scale as one uniform slider); rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. //! - **Export Template…** writes an open `.ectemplate`. @@ -134,6 +135,19 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { } }); app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting"); + // The selected property as a Font control (Source Text) or a uniform Scale slider. + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.add_enabled_ui(has_sel, |ui| { + ui.menu_button("Add Selected As", |ui| { + for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { + if ui.button(label).clicked() { + actions.push(("essential.addProperty".into(), json!({"as": as_type}))); + ui.close(); + } + } + }) + }); + app.auto.add("essential.addAs", r.response.rect, "Add Selected As"); let footage_layer = app.session.active_comp().and_then(|c| { app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0) }); @@ -184,7 +198,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg match src { Some((layer, lt, p)) => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) { + if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), essential::effective_type(c, &p), &p, &v) { actions.push(( "prop.set".into(), json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}), @@ -245,8 +259,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo } /// The editor for a control's value; returns the new value (JSON for `prop.set`). -fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option { - let ty = essential::control_type(p)?; +fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option, p: &Property, v: &KV) -> Option { + let ty = ty?; let mut out = None; let rect; match ty { @@ -319,6 +333,48 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Pro } rect = r.rect; } + ControlType::Font => { + // Family (from the installed fonts), style and size; the text stays as it is. + let KV::Text(doc) = v else { return None }; + let fams = effectcraft_engine::text::fonts::families(); + let mut d = (**doc).clone(); + let mut changed = false; + let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| { + for (f, _) in &fams { + if ui.selectable_label(*f == d.font, f).clicked() { + d.font = f.clone(); + changed = true; + } + } + }); + let styles: Vec = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default(); + let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| { + for st in &styles { + if ui.selectable_label(*st == d.style, st).clicked() { + d.style = st.clone(); + changed = true; + } + } + }); + let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); + app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); + app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); + if changed || r3.changed() { + out = serde_json::to_value(&d).ok(); + } + rect = r1.response.rect.union(r3.rect); + } + ControlType::Scale => { + // One percentage for every axis. + let comps = v.components(); + let mut x = comps.first().copied().unwrap_or(100.0); + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + if r.changed() { + out = Some(json!(vec![x; comps.len().max(1)])); + } + rect = r.rect; + } ControlType::Slider | ControlType::Media => { let mut x = v.as_f64(); let r = match p.ui { @@ -389,7 +445,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) } _ => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &auto, &p, &v) { + if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(c, &p), &p, &v) { actions.push(( "essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}), From 0698b23cd7e0387742608e810bafe0155ce67422 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:55:04 -0400 Subject: [PATCH 3/4] 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 7a7adb30..6381de0b 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 fb0473d4..414fae22 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 00000000..77e0f5d6 --- /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 7abd2923..368af0e3 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 2ed358b5..0e9dd04d 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 9ef10e09..54232fe1 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 00000000..1034f3d5 --- /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 00000000..f4932bc0 --- /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 00000000..e6228112 --- /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 00000000..b10cf8d5 --- /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 d299e350..1c3b2949 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 57a45ab1..b991be9b 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 00000000..4866619d --- /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 00000000..13abe9c6 --- /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 39770ee1..fe95d097 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 d75972f7..ac06485e 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 153533c4..2c24329a 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 28c04837..1fb2ef79 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 48587f9e..ffa14393 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 f3800ef6..2e58697b 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 d82c6d97..8ebd6ad3 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 00000000..1fc5b808 --- /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); +} From e98ea3b111c1ba37274d807088f5ebb3b4d42fea Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 07:55:24 -0400 Subject: [PATCH 4/4] M13.7: parity notes (AUT-2, CMP-7, menu items) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + docs/parity.md | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3dcb41c..ef445099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,6 +1223,7 @@ dependencies = [ "effectcraft-text", "effectcraft-time", "effectcraft-track", + "exr", "image", "kurbo", "log", diff --git a/docs/parity.md b/docs/parity.md index dc49af2e..79151d6b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -18,10 +18,14 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much Partial features: effect implementations (42 simplified effects), the Preview panel's settings (range, play from, skip, frame rate, full screen), GPU coverage (Advanced 3D, more GPU effects), the web app (no threads for viewer frames and Roto Brush), the Learn panel and Align to Selection / -Distribute, Advanced 3D (iris shapes, collapsed precomps, extruded strokes), ScriptUI resource -strings and `onDraw`, Essential Graphics font and scale controls, and the learned-model quality of -Roto Brush and face tracking (both are classical). Menu items still absent: Save Frame As ▸ -Photoshop Layers / ProEXR, Open in Essential Graphics, Vanishing Point import, Watch Folder. +Distribute, Advanced 3D (iris shapes, collapsed precomps, extruded strokes), and the learned-model +quality of Roto Brush and face tracking (both are classical). M13.7 added ScriptUI resource strings, +`onDraw` (ScriptUIGraphics draw lists), live `onChanging`, `Socket`, the Essential Graphics +scripting hooks and Font / uniform Scale controls, and the last missing menu items: Save Frame As ▸ +Photoshop Layers / ProEXR, Composition ▸ Open in Essential Graphics, File ▸ Watch Folder, +Window ▸ Create Nulls From Paths / VR Comp Editor (built-in panels) and Help ▸ In-App / Online +Tutorials. File ▸ Import ▸ Vanishing Point (.vpe) is present but permanently disabled: the format +has no public specification, so a clean-room implementation is not possible (its tooltip says so). ### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% @@ -125,9 +129,9 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | | Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | -| Automation | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | +| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | -| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | +| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |