mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
Merge main into M13.11 (bokeh kernel_spans/blur_levels, Essential Graphics as_type controls with mirrors and links)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
+113
-38
@@ -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
|
||||
@@ -645,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]);
|
||||
};
|
||||
@@ -805,22 +888,6 @@ Property.prototype.property = function () { return null; };
|
||||
};
|
||||
P.keyLabel = function () { return 0; };
|
||||
P.setLabelAtKey = function () {};
|
||||
// Essential Graphics: `comp` must be the comp holding the property. A property already in
|
||||
// the panel is added again as a mirror (both controls edit one value).
|
||||
P.canAddToMotionGraphicsTemplate = function (comp) {
|
||||
var c = comp && comp.__id !== undefined ? comp.__id : this.__layer.__comp;
|
||||
if (c !== this.__layer.__comp) return false;
|
||||
var sup = __call("essential.list", { comp: c, supported: true }).supported || [], uid = this.__uid;
|
||||
for (var i = 0; i < sup.length; i++) if (sup[i].uid === uid) return true;
|
||||
return false;
|
||||
};
|
||||
P.addToMotionGraphicsTemplateAs = function (comp, name) {
|
||||
if (!this.canAddToMotionGraphicsTemplate(comp)) return false;
|
||||
var p = this.__ref();
|
||||
if (name !== undefined) p.name = String(name);
|
||||
return __call("essential.addProperty", p).controls.length > 0;
|
||||
};
|
||||
P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); };
|
||||
})();
|
||||
|
||||
// Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers.
|
||||
@@ -1366,38 +1433,46 @@ 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]); };
|
||||
// Essential Graphics template: name, controllers (property, mirror and media controls in
|
||||
// panel order, 1-based) and export.
|
||||
P.__egControllers = function () {
|
||||
// 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 go(v) {
|
||||
for (var i = 0; i < v.length; i++) {
|
||||
if (v[i].kind === "group") go(v[i].children || []);
|
||||
else if (v[i].kind !== "comment") out.push(v[i]);
|
||||
(function walk(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].kind === "property" || list[i].kind === "mirror" || list[i].kind === "media") out.push(list[i]);
|
||||
if (list[i].children) walk(list[i].children);
|
||||
}
|
||||
})(__call("essential.list", { comp: this.__id }).controls || []);
|
||||
})(this.__eg().controls);
|
||||
return out;
|
||||
};
|
||||
def("motionGraphicsTemplateName", function () { return __call("essential.list", { comp: this.__id }).name; }, function (v) {
|
||||
def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) {
|
||||
__call("essential.setName", { comp: this.__id, name: String(v) });
|
||||
});
|
||||
def("motionGraphicsTemplateControllerCount", function () { return this.__egControllers().length; });
|
||||
def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; });
|
||||
P.getMotionGraphicsTemplateControllerName = function (i) {
|
||||
var c = this.__egControllers()[__num(i, "index") - 1];
|
||||
if (!c) throw __err("controller index out of range");
|
||||
return c.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);
|
||||
return c[i - 1].name;
|
||||
};
|
||||
P.setMotionGraphicsControllerName = function (i, name) {
|
||||
var c = this.__egControllers()[__num(i, "index") - 1];
|
||||
if (!c) throw __err("controller index out of range");
|
||||
__call("essential.rename", { comp: this.__id, control: c.id, name: String(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) {
|
||||
if (path === undefined) return false;
|
||||
var p = path && path.fsName !== undefined ? path.fsName : String(path);
|
||||
if (!overwrite && new File(p).exists) return false;
|
||||
try { __call("essential.exportTemplate", { comp: this.__id, path: p }); return true; } catch (e) { return false; }
|
||||
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;
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -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<Context, String> {
|
||||
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<JsValue>); 9] = [
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 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),
|
||||
|
||||
+291
-13
@@ -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");
|
||||
|
||||
@@ -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<JsValue> {
|
||||
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<u8>, eof: bool },
|
||||
Listener(TcpListener),
|
||||
}
|
||||
|
||||
struct Table {
|
||||
next: u32,
|
||||
socks: BTreeMap<u32, Sock>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Mutex<Table> {
|
||||
static T: Mutex<Table> = 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<Vec<std::net::SocketAddr>, 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<u8> {
|
||||
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<u8>, 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<u8> = 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<u8> = match buf.iter().position(|&c| c == b'\n') {
|
||||
Some(i) => {
|
||||
let mut l: Vec<u8> = 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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,37 +450,24 @@ fn layer_switches_timing_and_comp_settings() {
|
||||
#[test]
|
||||
fn motion_graphics_template_hooks_add_mirrors_and_controllers() {
|
||||
let mut s = session();
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-eg-script-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("card.ectemplate");
|
||||
let o = ok(
|
||||
&mut s,
|
||||
&format!(
|
||||
r#"
|
||||
r#"
|
||||
var comp = app.project.items.addComp("Card", 64, 64, 1, 2, 30);
|
||||
var bg = comp.layers.addSolid([1, 1, 1], "BG", 64, 64, 1);
|
||||
var op = bg.transform.opacity;
|
||||
var r = [op.canAddToMotionGraphicsTemplate(comp), op.addToMotionGraphicsTemplateAs(comp, "Fade")];
|
||||
// Again: a mirror of the same property.
|
||||
// Again: a mirror of the same property (a controller of its own).
|
||||
r.push(op.addToMotionGraphicsTemplate(comp));
|
||||
r.push(comp.motionGraphicsTemplateControllerCount);
|
||||
r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2));
|
||||
comp.setMotionGraphicsControllerName(2, "Fade Again");
|
||||
comp.motionGraphicsTemplateName = "Lower Card";
|
||||
r.push(comp.getMotionGraphicsTemplateControllerName(2), comp.motionGraphicsTemplateName);
|
||||
// A property of another comp can't be added.
|
||||
var other = app.project.items.addComp("Other", 64, 64, 1, 2, 30);
|
||||
r.push(op.canAddToMotionGraphicsTemplate(other));
|
||||
r.push(comp.exportAsMotionGraphicsTemplate(true, new File("{}")));
|
||||
r.push(comp.getMotionGraphicsTemplateControllerName(2));
|
||||
r
|
||||
"#,
|
||||
path.to_string_lossy()
|
||||
),
|
||||
);
|
||||
assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again", "Lower Card", false, true]));
|
||||
assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again"]));
|
||||
let (_, c) = comp_named(&s, "Card");
|
||||
let eg = c.essential.as_ref().unwrap();
|
||||
assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id));
|
||||
assert!(path.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 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<WidgetKind> = 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();
|
||||
}
|
||||
|
||||
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 create_nulls_from_paths_expressions_evaluate() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
|
||||
s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap();
|
||||
s.execute("paths.tracePath", json!({"layer": l})).unwrap();
|
||||
// Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex).
|
||||
let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)");
|
||||
let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let parts: Vec<Vec<f64>> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect();
|
||||
assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space");
|
||||
assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex");
|
||||
// Halfway in time along the closed square: the opposite corner.
|
||||
assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]);
|
||||
// Points Follow Nulls: moving the last vertex's null moves the vertex.
|
||||
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap();
|
||||
let last = r["nulls"][3].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap();
|
||||
let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')";
|
||||
let o = run_code(&mut s, code, "x.jsx");
|
||||
assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sockets_talk_tcp_behind_the_network_preference() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user