Merge main into fix/1063-text-animator-stacked

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U
This commit is contained in:
Claude
2026-10-29 09:27:06 +00:00
909 changed files with 87377 additions and 50172 deletions
+3
View File
@@ -27,3 +27,6 @@ boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-expr = { workspace = true }
effectcraft-psd = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
socket2 = "0.6"
-20
View File
@@ -88,27 +88,7 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_callable;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_include;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_tasks;
#[cfg(test)]
mod tests_ui;
#[cfg(test)]
mod tests_ui_more;
// #760: `$.evalFile` and `BridgeTalk`.
#[cfg(test)]
mod tests_evalfile;
// `/browser/…` is the web editor's file table, an absolute path on wasm; on Windows it has no
// drive, so the script runtime resolves it under the current drive, where the host holds nothing.
#[cfg(all(test, not(windows)))]
mod tests_services;
#[cfg(test)]
mod tests_ui_mouse;
+46 -6
View File
@@ -237,6 +237,14 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("merge", "mode", "ADBE Vector Merge Type"),
("wiggle", "size", "ADBE Vector Roughen Size"),
("wiggle", "detail", "ADBE Vector Roughen Detail"),
("wiggle", "points", "ADBE Vector Roughen Points"),
("wiggle", "speed", "ADBE Vector Temporal Freq"),
("wiggle", "correlation", "ADBE Vector Correlation"),
("wiggle", "phase", "ADBE Vector Temporal Phase"),
("wiggle", "spatialPhase", "ADBE Vector Spatial Phase"),
("wiggle", "seed", "ADBE Vector Random Seed"),
];
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
@@ -427,7 +435,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> {
}
/// Effects whose documented parameter match names are not numbered in display order:
/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`.
/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of
/// these effects that After Effects doesn't have are numbered after the listed ones (#1405).
const PARAM_NUMBERS: &[(&str, &str, usize)] = &[
("ec.generate.fill", "fillMask", 1),
("ec.generate.fill", "color", 2),
@@ -436,8 +445,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[
("ec.generate.fill", "opacity", 5),
("ec.generate.fill", "invert", 6),
("ec.generate.fill", "allMasks", 7),
("ec.generate.stroke", "path", 1),
("ec.generate.stroke", "color", 2),
("ec.generate.stroke", "brushSize", 3),
("ec.generate.stroke", "brushHardness", 4),
("ec.generate.stroke", "opacity", 5),
("ec.generate.stroke", "spacing", 6),
("ec.generate.stroke", "paintStyle", 7),
("ec.generate.stroke", "start", 8),
("ec.generate.stroke", "end", 9),
("ec.generate.stroke", "allMasks", 10),
("ec.generate.stroke", "strokeSequentially", 11),
// -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel
// controls are ours (After Effects edits the selected channel with the Master ones).
("ec.color.huesaturation", "channelControl", 2),
("ec.color.huesaturation", "hue", 4),
("ec.color.huesaturation", "saturation", 5),
("ec.color.huesaturation", "lightness", 6),
("ec.color.huesaturation", "colorize", 7),
("ec.color.huesaturation", "colorizeHue", 8),
("ec.color.huesaturation", "colorizeSaturation", 9),
("ec.color.huesaturation", "colorizeLightness", 10),
];
/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`.
fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize {
let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n);
if let Some(n) = listed(id) {
return n;
}
let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else {
return pos + 1;
};
let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count();
last + unlisted_before + 1
}
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
pub fn node(chain: &[&Node]) -> String {
@@ -460,11 +503,8 @@ pub fn node(chain: &[&Node]) -> String {
let fx = parent.and_then(Node::as_group);
match fx {
Some(g) => {
let i = PARAM_NUMBERS
.iter()
.find(|(fx, p, _)| *fx == g.match_id && *p == id)
.map(|(_, _, n)| *n)
.unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1);
let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0);
let i = param_number(&g.match_id, &g.children, id, pos);
format!("{}-{i:04}", effect(&g.match_id))
}
None => own(),
+1
View File
@@ -85,6 +85,7 @@ fn project(s: &Session) -> J {
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 },
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"displayStartFrame": p.settings.frame_start,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
})
+68 -40
View File
@@ -98,6 +98,8 @@ var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "CO
ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS;
ImportAsType.COMPOSITION = ImportAsType.COMP;
var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012);
// After Effects' values (FC_START_0 = 2612), as in docsforadobe/Types-for-Adobe.
var FramesCountType = __enum("FramesCountType", ["FC_START_0", "FC_START_1", "FC_TIMECODE_CONVERSION"], 2612);
var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212);
var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0);
@@ -187,7 +189,12 @@ BridgeTalk.pump = function () { return false; };
// ------------------------------------------------------------------ File / Folder
function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; }
function __trimPathSeparators(path) {
var end = path.length;
while (end > 0 && (path.charAt(end - 1) === "/" || path.charAt(end - 1) === "\\")) end--;
return path.slice(0, end);
}
function __join(a, b) { return __trimPathSeparators(a) + "/" + b; }
function File(path) {
if (!(this instanceof File)) return new File(path);
@@ -290,9 +297,9 @@ Folder.prototype = {
get fsName() { return this.__path; },
get fullName() { return this.__path; },
get absoluteURI() { return this.__path; },
get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); },
get name() { return __trimPathSeparators(this.__path).split(/[\/\\]/).pop(); },
get displayName() { return this.name; },
get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); },
get path() { var p = __trimPathSeparators(this.__path).split(/[\/\\]/); p.pop(); return p.join("/"); },
get parent() { return new Folder(this.path); },
get exists() { return JSON.parse(__file("isDir", this.__path)) === true; },
toString: function () { return this.__path; },
@@ -418,7 +425,7 @@ function __callable(o) {
delete f.length;
Object.setPrototypeOf(f, Object.getPrototypeOf(o));
var keys = Object.keys(o);
for (var i = 0; i < keys.length; i++) f[keys[i]] = o[keys[i]];
for (const elem of keys) f[elem] = o[elem];
var self = new Proxy(f, {
get: __attr.get,
apply: function (t, thisArg, args) { return self.property(args[0]); },
@@ -471,7 +478,7 @@ function MarkerValue(comment, chapter, url, frameTarget, cuePointName, params) {
}
MarkerValue.prototype.getParameters = function () {
var o = {};
for (var i = 0; i < this.__params.length; i++) o[this.__params[i][0]] = this.__params[i][1];
for (const elem of this.__params) o[elem[0]] = elem[1];
return o;
};
MarkerValue.prototype.setParameters = function (o) {
@@ -511,8 +518,8 @@ TextDocument.prototype = {
set text(v) { this.__text = String(v); this.__textSet = true; },
__style: function (k, d) {
if (k in this.__changed) return this.__changed[k];
if (this.__base && this.__base.first && k in this.__base.first) return this.__base.first[k];
if (this.__base && this.__base.para && k in this.__base.para) return this.__base.para[k];
if (this.__base?.first && k in this.__base.first) return this.__base.first[k];
if (this.__base?.para && k in this.__base.para) return this.__base.para[k];
return d;
},
__set: function (k, v) { this.__changed[k] = v; },
@@ -578,14 +585,14 @@ TextDocument.prototype = {
},
get tsume() { return this.__style("tsume", 0); },
set tsume(v) { this.__set("tsume", __num(v)); },
get boxText() { return !!(this.__base && this.__base.box_size); },
get boxText() { return !!this.__base?.box_size; },
get pointText() { return !this.boxText; },
get boxTextSize() { return this.__base && this.__base.box_size ? this.__base.box_size.slice() : undefined; },
get boxTextSize() { return this.__base?.box_size ? this.__base.box_size.slice() : undefined; },
set boxTextSize(v) {
var p = this.boxTextPos || [0, 0];
this.__set("box", [p[0], p[1], v[0], v[1]]);
},
get boxTextPos() { return this.__base && this.__base.box_size ? this.__base.box_pos.slice() : undefined; },
get boxTextPos() { return this.__base?.box_size ? this.__base.box_pos.slice() : undefined; },
get startIndent() { return this.__style("indentLeft", 0); },
set startIndent(v) { this.__set("indentLeft", __num(v)); },
get endIndent() { return this.__style("indentRight", 0); },
@@ -663,7 +670,7 @@ PropertyBase.prototype = {
get isMask() { return !!this.__info().isMask; },
get selected() {
var c = __get("item", { id: this.__layer.__comp }).selectedProperties;
for (var i = 0; i < c.length; i++) if (c[i][0] === this.__layer.__id && c[i][1] === this.__uid) return true;
for (const elem of c) if (elem[0] === this.__layer.__id && elem[1] === this.__uid) return true;
return false;
},
set selected(v) {
@@ -839,7 +846,7 @@ Property.prototype.property = function () { return null; };
i = __num(i, "keyIndex");
// Only the key asked for: fetching the whole list made every key call linear (#814).
var r = i >= 1 && Math.floor(i) === i ? __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, index: i - 1 }) : null;
if (!r || !r.key) throw __err("keyIndex " + i + " is out of range 1.." + (r ? r.count : this.numKeys));
if (!r?.key) throw __err("keyIndex " + i + " is out of range 1.." + (r ? r.count : this.numKeys));
return r.key;
};
// Script value → engine JSON for `prop.set` / `prop.addKey`.
@@ -850,13 +857,13 @@ Property.prototype.property = function () { return null; };
return __get("textdoc", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: time, attrs: td.__attrs() });
}
if (t === "SHAPE") {
if (!v || !v.vertices) throw __err("value must be a Shape");
if (!v?.vertices) throw __err("value must be a Shape");
var n = v.vertices.length;
function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a && a[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; }
function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a?.[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; }
return { vertices: fit(v.vertices), in_tangents: fit(v.inTangents), out_tangents: fit(v.outTangents), closed: v.closed !== false };
}
if (t === "LAYER_INDEX") {
var idx = v && v.__id !== undefined ? v.index : __num(v);
var idx = v?.__id !== undefined ? v.index : __num(v);
if (!idx) return null;
return this.__layer.containingComp.layer(idx).__id;
}
@@ -868,8 +875,11 @@ Property.prototype.property = function () { return null; };
}
if (info.dims > 1) {
if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers");
// After Effects rejects an array of the wrong length instead of truncating or broadcasting it
// (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0.
if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension");
var o = [];
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1])));
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0);
return o;
}
if (v instanceof Array) throw __err("value must be a number, not an array");
@@ -1171,7 +1181,7 @@ Layer.prototype = {
},
get selectedProperties() {
var c = this.containingComp.__info().selectedProperties, out = [];
for (var i = 0; i < c.length; i++) if (c[i][0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: c[i][1] })));
for (const elem of c) if (elem[0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: elem[1] })));
return out;
},
get parent() { var p = this.__info().parent; return p === null ? null : __layer(this.__comp, p); },
@@ -1199,7 +1209,7 @@ Layer.prototype = {
var before = this.containingComp.__info().layers;
__call("edit.duplicate", this.__p());
var after = this.containingComp.__info().layers;
for (var i = 0; i < after.length; i++) if (before.indexOf(after[i]) < 0) return __layer(this.__comp, after[i]);
for (const elem of after) if (before.indexOf(elem) < 0) return __layer(this.__comp, elem);
return null;
},
copyToComp: function (comp) {
@@ -1300,7 +1310,14 @@ AVLayer.prototype.constructor = AVLayer;
}
P.sourcePointToComp = function (p) { return pointIn(this, p, true); };
P.compPointToSource = function (p) { return pointIn(this, p, false); };
P.replaceSource = function () { throw __err("replaceSource is not supported yet"); };
// Replace the layer's source item, keeping every layer property (After Effects'
// AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are
// replaced, so the property tree and the layer's name stay and expressions keep resolving.
P.replaceSource = function (newSource, fixExpressions) {
if (newSource?.__id === undefined) throw __err("replaceSource needs a project item");
__call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions });
return null;
};
P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; };
})();
@@ -1358,7 +1375,7 @@ LayerCollection.prototype = {
return layer;
},
add: function (item, duration) {
if (!item || item.id === undefined) throw __err("add(): expected an item");
if (item?.id === undefined) throw __err("add(): expected an item");
var l = this.__new(__call("layer.addItem", { comp: this.__comp, item: item.id, time: 0 }));
return this.__dur(l, duration);
},
@@ -1401,15 +1418,15 @@ LayerCollection.prototype = {
},
precompose: function (indices, name, moveAllAttributes) {
var ids = this.__ids(), sel = [];
for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]);
for (const elem of indices) sel.push(ids[elem - 1]);
var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" });
return __item(r.comp);
},
byName: function (name) {
var ids = this.__ids();
for (var i = 0; i < ids.length; i++) {
var l = __get("layer", { comp: this.__comp, layer: ids[i] });
if (l.name === name) return __layer(this.__comp, ids[i]);
for (const elem of ids) {
var l = __get("layer", { comp: this.__comp, layer: elem });
if (l.name === name) return __layer(this.__comp, elem);
}
return null;
},
@@ -1480,7 +1497,7 @@ Item.prototype = {
get label() { return this.__info().label; },
set label(v) { __call("project.setLabel", { items: [this.__id], label: __num(v, "label") }); },
get parentFolder() { return __item(this.__info().parent); },
set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); },
set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f?.__id ? f.__id : null }); },
get selected() { return this.__info().selected; },
set selected(v) {
var selection = __get("project").selection;
@@ -1502,8 +1519,11 @@ function AVItem() {}
AVItem.prototype = Object.create(Item.prototype);
(function () {
function def(name, get, set) { Object.defineProperty(AVItem.prototype, name, { get: get, set: set }); }
def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")) }); });
def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")) }); });
// `anchor: 0` (top left): After Effects' width and height setters resize the frame and leave the
// layers at their coordinates. Re-anchoring the old frame in the new one belongs to the
// Composition Settings dialog, which is what `comp.settings` defaults to (#1942).
def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")), anchor: 0 }); });
def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")), anchor: 0 }); });
def("pixelAspect", function () { return this.__info().pixelAspect; }, function (v) { this.__settings({ pixelAspect: __num(v) }); });
def("duration", function () { return this.__info().duration; }, function (v) { this.__settings({ duration: __num(v, "duration") }); });
def("frameRate", function () { return this.__info().frameRate; }, function (v) { this.__settings({ frameRate: __num(v, "frameRate") }); });
@@ -1511,7 +1531,7 @@ AVItem.prototype = Object.create(Item.prototype);
def("hasVideo", function () { return this.__info().hasVideo; });
def("hasAudio", function () { return this.__info().hasAudio; });
def("footageMissing", function () { return !!this.__info().footageMissing; });
def("useProxy", function () { var p = this.__info().proxy; return !!(p && p.enabled); }, function (v) {
def("useProxy", function () { var p = this.__info().proxy; return !!p?.enabled; }, function (v) {
if (!this.__info().proxy) throw __err("\"" + this.name + "\" has no proxy");
__call("file.useProxy", { item: this.__id, on: !!v });
});
@@ -1548,9 +1568,9 @@ CompItem.prototype.constructor = CompItem;
});
def("activeCamera", function () {
var ids = this.__info().layers;
for (var i = 0; i < ids.length; i++) {
var l = __get("layer", { comp: this.__id, layer: ids[i] });
if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, ids[i]);
for (const elem of ids) {
var l = __get("layer", { comp: this.__id, layer: elem });
if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, elem);
}
return null;
});
@@ -1590,7 +1610,7 @@ CompItem.prototype.constructor = CompItem;
if (a < 1 || a > ids.length || Math.floor(a) !== a) throw __err("layer index " + a + " is out of range 1.." + ids.length);
return __layer(this.__id, ids[a - 1]);
}
if (a && a.__id !== undefined && typeof b === "number") {
if (a?.__id !== undefined && typeof b === "number") {
var j = a.index + b;
return j >= 1 && j <= ids.length ? __layer(this.__id, ids[j - 1]) : null;
}
@@ -1603,9 +1623,9 @@ CompItem.prototype.constructor = CompItem;
P.__controllers = function () {
var out = [];
(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);
for (const elem of list) {
if (elem.kind === "property" || elem.kind === "mirror" || elem.kind === "media") out.push(elem);
if (elem.children) walk(elem.children);
}
})(this.__eg().controls);
return out;
@@ -1716,7 +1736,7 @@ FolderItem.prototype.constructor = FolderItem;
Object.defineProperty(P, "numItems", { get: function () { return new ItemCollection(this.__id).length; } });
Object.defineProperty(P, "parentFolder", {
get: function () { return this.__id === 0 ? null : __item(this.__info().parent); },
set: function (f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); },
set: function (f) { __call("project.move", { items: [this.__id], folder: f?.__id ? f.__id : null }); },
});
P.item = function (i) {
var v = new ItemCollection(this.__id).__at(__num(i, "index"));
@@ -1760,7 +1780,7 @@ RenderQueueItem.prototype = {
constructor: RenderQueueItem,
__info: function () {
var l = __call("renderQueue.list", {}).items;
for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i];
for (const elem of l) if (elem.id === this.__id) return elem;
throw __err("the render queue item no longer exists");
},
get comp() { return __item(this.__info().comp); },
@@ -1796,7 +1816,7 @@ RenderQueueItem.prototype = {
var before = __call("renderQueue.list", {}).items.map(function (i) { return i.id; });
__call("renderQueue.duplicate", { item: this.__id });
var after = __call("renderQueue.list", {}).items;
for (var i = 0; i < after.length; i++) if (before.indexOf(after[i].id) < 0) return new RenderQueueItem(after[i].id);
for (const elem of after) if (before.indexOf(elem.id) < 0) return new RenderQueueItem(elem.id);
return null;
},
applyTemplate: function () {},
@@ -1883,6 +1903,14 @@ Project.prototype = {
set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); },
get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; },
set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); },
get framesCountType() { return this.__info().displayStartFrame === 1 ? FramesCountType.FC_START_1 : FramesCountType.FC_START_0; },
set framesCountType(v) {
if (v === FramesCountType.FC_TIMECODE_CONVERSION) throw __err("framesCountType: Timecode Conversion is not supported, use FC_START_0 or FC_START_1");
if (v !== FramesCountType.FC_START_0 && v !== FramesCountType.FC_START_1) throw __err("Bad framesCountType value " + v);
__call("file.projectSettings", { frameStart: v === FramesCountType.FC_START_1 ? 1 : 0 });
},
get displayStartFrame() { return this.__info().displayStartFrame; },
set displayStartFrame(v) { __call("file.projectSettings", { frameStart: __num(v) }); },
get expressionEngine() { return "javascript-1.0"; },
set expressionEngine(v) {},
item: function (i) {
@@ -1921,9 +1949,9 @@ Project.prototype = {
}
}
var r = __call("file.import", args);
if (r.errors && r.errors.length) throw __err(r.errors.join("; "));
if (r.errors?.length) throw __err(r.errors.join("; "));
// Composition imports create several footage items, but callers need the comp.
return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]);
return __item(r.comps?.length ? r.comps[0] : r.items[0]);
},
importFileWithDialog: function () { return null; },
importPlaceholder: function (name, w, h, rate, dur) {
+1 -1
View File
@@ -710,7 +710,7 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Ou
/// don't block.
///
/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file
/// and line they came from ([`crate::include`]).
/// and line they came from (`crate::include`).
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let expanded = match crate::include::expand(session, req.code, req.name) {
Ok(x) => x,
+34 -34
View File
@@ -213,7 +213,7 @@ 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]);
for (const elem of v.__object) o[elem[0]] = __uiResPlain(elem[1]);
return o;
}
return v;
@@ -222,8 +222,8 @@ function __uiResPlain(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];
for (const elem2 of res.list) {
var e = elem2;
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]);
}
@@ -239,17 +239,17 @@ function __uiApplyRes(c, res) {
// 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]);
if (props.items instanceof Array && c.items) for (const elem of props.items) c.add("item", elem);
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;
for (const elem of pk.kids) {
var child = __uiAddRes(c, elem[1]);
child.__key = elem[0];
c[elem[0]] = child;
}
return c;
}
@@ -268,7 +268,7 @@ Window.alert = function (s) { alert(s); };
Window.confirm = function (s) { return confirm(s); };
Window.prompt = function (m, d) { return prompt(m, d); };
Window.find = function (type, title) {
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].text === title) return __uiWins[i];
for (const elem of __uiWins) if (elem.text === title) return elem;
return null;
};
@@ -335,7 +335,7 @@ function __uiColor(c) {
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;
var x = f?.size !== undefined ? Number(f.size) : g?.font && g.font.size !== undefined ? Number(g.font.size) : 12;
return x > 0 ? x : 12;
}
@@ -371,11 +371,11 @@ function __uiGraphics() {
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() });
this.__push({ op: "stroke", color: pen ? pen.color : null, width: 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 : "") });
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?.style !== undefined ? f.style : "") });
},
drawOSControl: function () { this.__push({ op: "os" }); },
drawImage: function (img, x, y, w, h) { this.__push({ op: "image", image: __uiImageJson(img), x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); },
@@ -384,7 +384,7 @@ function __uiGraphics() {
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));
for (const elem of lines) w = Math.max(w, Math.ceil(elem.length * cw));
var n = lines.length;
if (boundaryWidth > 0 && w > boundaryWidth) {
n = Math.ceil(w / boundaryWidth) + lines.length - 1;
@@ -405,14 +405,14 @@ function __uiRunDraws(win) {
})(win);
if (!drawn.length) return;
__uiLayout(win);
for (var k = 0; k < drawn.length; k++) {
var c = drawn[k];
for (const elem of drawn) {
var c = elem;
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) {}
try { writeLn("onDraw: " + (e?.message ? e.message : e)); } catch (e2) {}
}
c.graphics.__rec = null;
}
@@ -498,7 +498,7 @@ function __uiProto() {
var arg = t === "image" || t === "iconbutton" ? text : typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text;
__uiInit(c, t, this.window, this, bounds, arg, props);
if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) {
for (var i = 0; i < text.length; i++) c.add("item", text[i]);
for (const elem of text) c.add("item", elem);
c.text = "";
}
if (t === "slider" || t === "scrollbar") {
@@ -521,7 +521,7 @@ function __uiProto() {
if (t === "checkbox" || t === "radiobutton") c.value = false;
if (t === "edittext" && c.properties.multiline) c.__multiline = true;
if (t === "edittext" && c.properties.readonly) c.__readonly = true;
if (c.properties && c.properties.name) c.name = c.properties.name;
if (c.properties?.name) c.name = c.properties.name;
this.children.push(c);
if (t === "tab" && this.type === "tabbedpanel" && this.__sel.length === 0) this.__sel = [this.children.length - 1];
return c;
@@ -608,21 +608,21 @@ function __uiProto() {
removeEventListener: function (name, fn, capture) {
var k = String(name).toLowerCase();
var m = capture ? this.__capture : this.__listeners;
if (m && m[k]) m[k] = m[k].filter(function (f) { return f !== fn; });
if (m?.[k]) m[k] = m[k].filter(function (f) { return f !== fn; });
},
dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); },
__fire: function (name) {
var r;
if (typeof this[name] === "function") r = this[name].call(this);
var l = this.__listeners[name.slice(2).toLowerCase()];
if (l) for (var i = 0; i < l.length; i++) l[i].call(this, { type: name.slice(2).toLowerCase(), target: this });
if (l) for (const elem of l) elem.call(this, { type: name.slice(2).toLowerCase(), target: this });
return r;
},
__handlers: function () {
var out = [];
var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"];
for (var i = 0; i < names.length; i++) {
var n = names[i];
for (const elem of names) {
var n = elem;
if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n);
}
return out;
@@ -634,8 +634,8 @@ function __uiProto() {
(function () {
var all = ["Window", "Panel", "Group", "Button", "IconButton", "StaticText", "EditText", "Checkbox", "RadioButton", "Slider",
"Scrollbar", "Progressbar", "DropDownList", "ListBox", "TabbedPanel", "Tab", "ScriptUIImage"];
for (var i = 0; i < all.length; i++) {
var C = __ecGlobal[all[i]];
for (const elem of all) {
var C = __ecGlobal[elem];
var p = __uiProto();
for (var k in p) C.prototype[k] = p[k];
Object.defineProperty(C.prototype, "bounds", {
@@ -737,8 +737,8 @@ function __uiMouseListeners(c, type, capture) {
// The mouse events that reach a listener at `c`: its own, and its containers' (`inherited`).
function __uiMouseWanted(c, inherited) {
var out = inherited.slice();
for (var i = 0; i < __uiMouseKinds.length; i++) {
var t = __uiMouseKinds[i];
for (const elem of __uiMouseKinds) {
var t = elem;
if (out.indexOf(t) < 0 && (__uiMouseListeners(c, t, false).length || __uiMouseListeners(c, t, true).length)) out.push(t);
}
return out;
@@ -793,7 +793,7 @@ function __uiMouseFire(target, type, v) {
var l = __uiMouseListeners(c, type, capture);
e.currentTarget = c;
e.eventPhase = phase;
for (var i = 0; i < l.length; i++) if (typeof l[i] === "function") l[i].call(c, e);
for (const elem of l) if (typeof elem === "function") elem.call(c, e);
};
var n = path.length - 1;
for (var i = 0; i < n; i++) {
@@ -832,7 +832,7 @@ function __uiJson(c, inherited) {
if (m) o.margins = m;
if (typeof c.spacing === "number") o.spacing = c.spacing;
var kids = c.children;
for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i], mouse));
for (const elem of kids) o.children.push(__uiJson(elem, mouse));
}
if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment);
if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value;
@@ -840,7 +840,7 @@ function __uiJson(c, inherited) {
o.value = Number(c.value) || 0;
o.min = Number(c.minvalue) || 0;
o.max = Number(c.maxvalue);
if (!(o.max === o.max)) o.max = 100;
if (Number.isNaN(o.max)) o.max = 100;
}
if (c.items) {
o.items = c.items.map(function (it) { return String(it.text); });
@@ -859,8 +859,8 @@ function __uiJson(c, inherited) {
// The open windows of this script, for the host.
function __uiSnapshot() {
var out = [];
for (var i = 0; i < __uiWins.length; i++) {
var w = __uiWins[i];
for (const elem of __uiWins) {
var w = elem;
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) });
}
@@ -871,12 +871,12 @@ function __uiSnapshot() {
function __uiOpenCount() {
var n = 0;
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__shown && !__uiWins[i].__closed) n++;
for (const elem of __uiWins) if (elem.__shown && !elem.__closed) n++;
return n;
}
function __uiWin(id) {
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__wid === id) return __uiWins[i];
for (const elem of __uiWins) if (elem.__wid === id) return elem;
return null;
}
function __uiFind(c, id) {
@@ -914,7 +914,7 @@ function __uiDispatch(winId, id, kind, valueJson) {
if (!c || c.enabled === false) return;
var radio = function () {
var sib = c.parent ? c.parent.children : [];
for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false;
for (const elem of sib) if (elem !== c && elem.type === "radiobutton") elem.value = false;
c.value = true;
};
// A mouse click carries the pointer's details ({x, y, button, detail, …keys}).
+331 -28
View File
@@ -50,20 +50,32 @@ mod imp {
mod imp {
use std::collections::BTreeMap;
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::Mutex;
use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use super::*;
use super::{J, err, json};
enum Sock {
Stream { s: TcpStream, buf: Vec<u8>, eof: bool },
Listener(TcpListener),
}
/// One open socket. Reading, writing and polling lock only it, never the whole table, so a
/// socket blocked on a slow peer doesn't hold up the others (#1248).
struct Entry {
sock: Mutex<Sock>,
/// A second handle on a stream, so `close` can wake a blocked read.
wake: Option<TcpStream>,
/// Closed or unusable after a mode error. Writes poll it because Windows sends may not
/// be interrupted by shutdown alone; queued operations recheck it after locking.
closed: AtomicBool,
}
struct Table {
next: u32,
socks: BTreeMap<u32, Sock>,
socks: BTreeMap<u32, Arc<Entry>>,
}
fn table() -> &'static Mutex<Table> {
@@ -72,13 +84,43 @@ mod imp {
}
fn add(s: Sock) -> u32 {
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
let wake = match &s {
Sock::Stream { s, .. } => s.try_clone().ok(),
Sock::Listener(_) => None,
};
let entry = Arc::new(Entry { sock: Mutex::new(s), wake, closed: AtomicBool::new(false) });
let mut t = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let id = t.next;
t.next += 1;
t.socks.insert(id, s);
t.socks.insert(id, entry);
id
}
/// The socket `id`, if open. The table is locked only for the lookup.
fn get(id: u32) -> Option<Arc<Entry>> {
table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).socks.get(&id).cloned()
}
/// Forget socket `id`; a call blocked on it fails at once.
fn close(id: u32) {
let entry = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).socks.remove(&id);
if let Some(entry) = entry {
entry.closed.store(true, Ordering::Release);
if let Some(w) = &entry.wake {
let _ = w.shutdown(Shutdown::Both);
}
}
}
/// A test-only handle for establishing backpressure before exercising the public operations.
#[cfg(test)]
pub(super) fn test_stream(id: u32) -> TcpStream {
let entry = get(id).unwrap();
let sock = entry.sock.lock().unwrap();
let Sock::Stream { s, .. } = &*sock else { panic!("expected a stream") };
s.try_clone().unwrap()
}
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))
}
@@ -98,8 +140,9 @@ mod imp {
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) {
/// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when
/// the read deadline can't be set.
fn fill(s: &mut TcpStream, buf: &mut Vec<u8>, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> {
let end = Instant::now() + limit;
let mut chunk = [0u8; 4096];
while !done(buf) && !*eof {
@@ -107,7 +150,7 @@ mod imp {
if left.is_zero() {
break;
}
let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1))));
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]),
@@ -119,9 +162,59 @@ mod imp {
Err(_) => *eof = true,
}
}
Ok(())
}
/// A failed mode transition leaves the stream unusable; reject queued operations and wake I/O.
fn change_mode(s: &TcpStream, entry: &Entry, nonblocking: bool, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> {
mode(s, nonblocking).map_err(|e| {
entry.closed.store(true, Ordering::Release);
let _ = s.shutdown(Shutdown::Both);
let action = if nonblocking { "enable nonblocking mode" } else { "restore blocking mode" };
std::io::Error::other(format!("socket is unusable: cannot {action}: {e}"))
})
}
/// Write with one deadline and an explicit close signal. On Windows, shutdown alone does
/// not reliably interrupt a blocking send, so never leave a send blocked inside the OS.
/// The caller holds this socket's lock; restore blocking mode or retire the connection.
fn write(s: &mut TcpStream, data: &[u8], limit: Duration, entry: &Entry, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> {
change_mode(s, entry, true, mode)?;
let end = Instant::now() + limit;
let result = (|| {
let mut pending = data;
while !pending.is_empty() {
if entry.closed.load(Ordering::Acquire) {
return Err(std::io::Error::new(ErrorKind::ConnectionAborted, "the socket was closed"));
}
let left = end.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::from(ErrorKind::TimedOut));
}
match s.write(pending) {
Ok(0) => return Err(std::io::Error::from(ErrorKind::WriteZero)),
Ok(n) => pending = pending.get(n..).ok_or_else(|| std::io::Error::from(ErrorKind::InvalidData))?,
Err(e) if e.kind() == ErrorKind::WouldBlock => std::thread::sleep(left.min(Duration::from_millis(5))),
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(())
})();
match (result, change_mode(s, entry, false, mode)) {
(Err(write), Err(restore)) => Err(std::io::Error::other(format!("{restore}; write also failed: {write}"))),
(result, Ok(())) => result,
(Ok(()), Err(restore)) => Err(restore),
}
}
/// Dispatch an authorized operation without holding the socket table during I/O.
pub(super) fn run(op: &str, id: u32, p: &J) -> J {
run_with_mode(op, id, p, TcpStream::set_nonblocking)
}
/// Dispatch with a mode setter so exceptional OS failures can be injected per operation.
pub(super) fn run_with_mode(op: &str, id: u32, p: &J, mode: impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> 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" => {
@@ -156,43 +249,54 @@ mod imp {
Err(e) => err(format!("cannot listen on port {port}: {e}")),
}
}
"close" => {
close(id);
json!({})
}
_ => {
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 {
let Some(entry) = get(id) else {
return match op {
"connected" => json!({"value": false}),
"eof" => json!({"value": true}),
_ => err("the socket is not open"),
};
};
match (op, sock) {
let mut sock = entry.sock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Recheck after locking: close or a failed mode transition may have retired it
// while this caller waited with an Arc obtained from the table.
if entry.closed.load(Ordering::Acquire) {
return match op {
"connected" => json!({"value": false}),
"eof" => json!({"value": true}),
_ => err("the socket is closed or unusable"),
};
}
match (op, &mut *sock) {
("poll", Sock::Listener(l)) => match l.accept() {
Ok((s, peer)) => {
let _ = s.set_nonblocking(false);
if let Err(e) = s.set_nonblocking(false) {
return err(format!("cannot use the connection from {peer}: {e}"));
}
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})
json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "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);
if let Err(e) = change_mode(s, &entry, true, &mode) {
return err(e);
}
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);
if let Err(e) = change_mode(s, &entry, false, &mode) {
return err(e);
}
}
json!({"value": !(*eof && buf.is_empty())})
}
@@ -202,19 +306,25 @@ mod imp {
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);
if let Err(e) = fill(s, buf, eof, limit, |_| false) {
return err(format!("cannot set the read timeout: {e}"));
}
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);
if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) {
return err(format!("cannot set the read timeout: {e}"));
}
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'));
if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) {
return err(format!("cannot set the read timeout: {e}"));
}
let line: Vec<u8> = match buf.iter().position(|&c| c == b'\n') {
Some(i) => {
let mut l: Vec<u8> = buf.drain(..=i).collect();
@@ -229,9 +339,16 @@ mod imp {
json!({"data": decode(&line, utf8)})
}
("write", Sock::Stream { s, .. }) => {
// A peer that stops reading fills the buffers: give up after `timeout`
// instead of blocking the script for good (#1248).
let limit = timeout(p).max(Duration::from_millis(1));
let data = p.get("data").and_then(J::as_str).unwrap_or("");
match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) {
match write(s, &encode(data, utf8), limit, &entry, &mode) {
Ok(()) => json!({"ok": true}),
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!(
"write timed out after {} s: the other side isn't reading (some of the data may have been sent)",
limit.as_secs_f64()
)),
Err(e) => err(e),
}
}
@@ -242,3 +359,189 @@ mod imp {
}
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde_json::{Value as J, json};
use super::imp::run;
/// A loopback peer that accepts one connection and never reads from it; returns its
/// `host:port` and the accepted stream's keeper (drop it to let the connection go).
fn stalled_peer() -> (String, mpsc::Sender<()>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
socket2::SockRef::from(&l).set_recv_buffer_size(4096).unwrap();
let host = l.local_addr().unwrap().to_string();
let (stop, wait) = mpsc::channel::<()>();
std::thread::spawn(move || {
let (_s, _): (TcpStream, _) = l.accept().unwrap();
let _ = wait.recv_timeout(Duration::from_secs(120));
});
(host, stop)
}
/// Open a test connection through the production socket dispatcher.
fn open(host: &str) -> u32 {
let r = run("open", 0, &json!({"host": host, "timeout": 5}));
r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32
}
/// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls.
/// In particular, Windows may accept that entire write with its default send buffering.
fn backpressure(id: u32) {
let mut stream = super::imp::test_stream(id);
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
stream.set_nonblocking(true).unwrap();
let start = Instant::now();
let chunk = [b'x'; 4096];
let mut sent = 0;
loop {
assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure");
match stream.write(&chunk) {
Ok(0) => panic!("peer closed while establishing backpressure"),
Ok(n) => sent += n,
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => panic!("establishing backpressure: {e}"),
}
}
stream.set_nonblocking(false).unwrap();
}
/// Run `f` on a thread; its result, or `None` if it takes longer than `limit`.
fn within<T: Send + 'static>(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(limit).ok()
}
/// A bounded write times out even when the peer never drains its receive buffer.
#[test]
fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() {
// #1248: it used to block for good.
let (host, _keep) = stalled_peer();
let id = open(&host);
backpressure(id);
let data = "x".repeat(64 * 1024);
let t0 = Instant::now();
let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up");
assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}");
assert!(t0.elapsed() >= Duration::from_millis(500) && t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed());
run("close", id, &J::Null);
}
/// Closing a stalled stream interrupts its writer without blocking another socket.
#[test]
fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() {
let (host_a, _keep_a) = stalled_peer();
let (host_b, _keep_b) = stalled_peer();
let (a, b) = (open(&host_a), open(&host_b));
// A write on A that would wait a minute.
backpressure(a);
let data = "x".repeat(64 * 1024);
let (tx, blocked) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60})));
});
std::thread::sleep(Duration::from_millis(500));
assert!(matches!(blocked.try_recv(), Err(mpsc::TryRecvError::Empty)), "A must still be writing before close");
// Socket B answers meanwhile (the table isn't locked by A's write).
let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked");
assert_eq!(r["value"], true, "{r}");
// Closing A returns at once and ends A's write.
within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked");
let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed");
assert!(r.get("error").is_some(), "{r}");
run("close", b, &J::Null);
}
/// Failed mode changes retire the stream; subsequent I/O fails instead of using it.
#[test]
fn mode_restoration_failure_retires_streams() {
for (op, fail_nonblocking) in [("write", false), ("connected", false), ("write", true), ("connected", true)] {
let (host, _keep) = stalled_peer();
let id = open(&host);
let result = super::imp::run_with_mode(op, id, &json!({"data": "hello"}), |s, nonblocking| {
if nonblocking == fail_nonblocking { Err(std::io::Error::other("injected mode failure")) } else { s.set_nonblocking(nonblocking) }
});
assert!(result["error"].as_str().is_some_and(|e| e.contains("injected mode failure")), "{op}: {result}");
assert_eq!(run("connected", id, &J::Null)["value"], false, "{op}: broken socket remains connected");
assert_eq!(run("eof", id, &J::Null)["value"], true);
for operation in ["read", "readln", "write"] {
let r = run(operation, id, &json!({"count": 1, "timeout": 0.01, "data": "x"}));
assert!(r.get("error").is_some(), "{operation} must reject the unusable stream: {r}");
}
run("close", id, &J::Null);
run("close", id, &J::Null);
}
}
/// A preceding write error cannot hide the failure that makes the stream unusable.
#[test]
fn restoration_failure_preserves_the_write_error_too() {
let (host, _keep) = stalled_peer();
let id = open(&host);
let result = super::imp::run_with_mode("write", id, &json!({"data": "hello"}), |s, nonblocking| {
if nonblocking {
s.set_nonblocking(true)?;
s.shutdown(std::net::Shutdown::Write)
} else {
Err(std::io::Error::other("injected restore failure"))
}
});
let error = result["error"].as_str().unwrap();
assert!(error.contains("injected restore failure") && error.contains("write also failed"), "{error}");
assert_eq!(run("connected", id, &J::Null)["value"], false);
run("close", id, &J::Null);
}
/// Both outgoing and accepted sockets deliver complete data, then return to blocking reads.
#[test]
fn writes_deliver_all_data_and_restore_blocking_reads() {
for accepted in [false, true] {
let (id, mut peer) = if accepted {
let listener = run("listen", 0, &json!({"port": 0}));
let listener_id = listener["id"].as_u64().unwrap() as u32;
let peer = TcpStream::connect(("127.0.0.1", listener["port"].as_u64().unwrap() as u16)).unwrap();
let start = Instant::now();
let id = loop {
if let Some(id) = run("poll", listener_id, &J::Null)["id"].as_u64() {
break id as u32;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
run("close", listener_id, &J::Null);
(id, peer)
} else {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let id = open(&listener.local_addr().unwrap().to_string());
(id, listener.accept().unwrap().0)
};
socket2::SockRef::from(&super::imp::test_stream(id)).set_send_buffer_size(4096).unwrap();
peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let reader = std::thread::spawn(move || {
let mut data = vec![0; 256 * 1024];
peer.read_exact(&mut data).unwrap();
assert!(data.iter().all(|b| *b == b'x'));
// A read on a socket accidentally left nonblocking would return before this.
std::thread::sleep(Duration::from_millis(100));
peer.write_all(b"ack").unwrap();
});
let r = within(Duration::from_secs(10), move || run("write", id, &json!({"data": "x".repeat(256 * 1024), "timeout": 5}))).unwrap();
assert_eq!(r["ok"], true, "{r}");
let r = run("read", id, &json!({"count": 3, "timeout": 2}));
assert_eq!(r["data"], "ack", "{r}");
run("close", id, &J::Null);
reader.join().unwrap();
}
}
}
+301
View File
@@ -10,6 +10,18 @@ use serde_json::{Value, json};
use crate::{Outcome, run_code};
mod callable;
mod ease_presets;
mod evalfile;
mod include;
mod panels;
#[cfg(not(windows))]
mod services;
mod tasks;
mod ui;
mod ui_more;
mod ui_mouse;
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
crate::install(&mut s);
@@ -126,6 +138,65 @@ fn fill_parameters_use_documented_match_names() {
assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007"));
}
#[test]
fn stroke_parameters_use_after_effects_match_names() {
// #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks.
let mut s = session();
let o = ok(
&mut s,
r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = [];
for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name);
out.push(fx.property("Color").matchName); out"#,
);
assert_eq!(
o.result,
json!([
"Path",
"Color",
"Brush Size",
"Brush Hardness",
"Opacity",
"Spacing",
"Paint Style",
"Start",
"End",
"All Masks",
"Stroke Sequentially",
"ADBE Stroke-0002"
])
);
}
#[test]
fn hue_saturation_parameters_use_after_effects_match_names() {
// #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls,
// which After Effects doesn't expose, are numbered after its parameters so none takes an AE number.
let mut s = session();
let o = ok(
&mut s,
r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = [];
var n = [2, 4, 5, 6, 7, 8, 9, 10];
for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name);
out.push(fx.property("Red Hue").matchName); out"#,
);
assert_eq!(
o.result,
json!([
"Channel Control",
"Master Hue",
"Master Saturation",
"Master Lightness",
"Colorize",
"Colorize Hue",
"Colorize Saturation",
"Colorize Lightness",
"ADBE HUE SATURATION-0011"
])
);
}
#[test]
fn undo_steps_without_a_group() {
let mut s = session();
@@ -199,6 +270,44 @@ fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function()
assert_eq!(e.line, Some(6), "{e:?}");
}
/// Scripts reach Wiggle Paths' properties, Spatial Phase included, by their match names.
#[test]
fn wiggle_paths_properties_by_match_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
var sh = c.layers.addShape();
var w = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Filter - Roughen");
w.property("ADBE Vector Spatial Phase").setValue(45);
var names = [];
for (var i = 1; i <= w.numProperties; i++) names.push(w.property(i).matchName);
[w.matchName, w.property("ADBE Vector Spatial Phase").name, names]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE Vector Filter - Roughen",
"Spatial Phase",
[
"ADBE Vector Roughen Size",
"ADBE Vector Roughen Detail",
"ADBE Vector Roughen Points",
"ADBE Vector Temporal Freq",
"ADBE Vector Correlation",
"ADBE Vector Temporal Phase",
"ADBE Vector Spatial Phase",
"ADBE Vector Random Seed"
]
])
);
let (_, c) = comp_named(&s, "W");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/wiggle/spatialPhase").unwrap().value, KV::Scalar(45.0));
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
@@ -385,6 +494,38 @@ fn layers_items_and_markers() {
);
}
#[test]
fn folder_paths_trim_trailing_separators() {
let mut s = session();
let o = ok(
&mut s,
r#"
var paths = ['', '/', '///', 'a/b/', 'a\\b\\/', 'a/b', 'a/\n'];
var result = paths.map(function (path) {
var folder = new Folder(path);
return [folder.name, folder.path, __join(path, 'child')];
});
// A long run followed by a non-separator used to cause quadratic regex backtracking.
var longPath = Array(20001).join('/') + 'x';
result.push([new Folder(longPath).name, new Folder(longPath).path.length, __join(longPath, 'child').length]);
result
"#,
);
assert_eq!(
o.result,
json!([
["", "", "/child"],
["", "", "/child"],
["", "", "/child"],
["b", "a", "a/b/child"],
["b", "a", "a\\b/child"],
["b", "a", "a/b/child"],
["\n", "a", "a/\n/child"],
["x", 19999, 20007]
])
);
}
#[test]
fn security_gate_for_files() {
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
@@ -552,6 +693,26 @@ fn engine_entry_points() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807).
#[test]
fn frames_count_type_sets_the_project_frame_count() {
let mut s = session();
let o = ok(
&mut s,
"var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')",
);
assert_eq!(o.result, json!("true|0|true|1"));
// After Effects' enum values, so scripts that store the number keep working.
let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')");
assert_eq!(o.result, json!("2612|2613|2614"));
assert_eq!(s.project.settings.frame_start, 1);
ok(&mut s, "app.project.displayStartFrame = 0");
assert_eq!(s.project.settings.frame_start, 0);
let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx");
assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion")));
assert_eq!(s.project.settings.frame_start, 0);
}
#[test]
fn console_keeps_variables() {
let mut s = session();
@@ -904,6 +1065,37 @@ fn footage_proxies_sequences_and_conform_rate() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and
/// properties; a folder is not a source.
#[test]
fn av_layer_replace_source_keeps_the_layer() {
let mut s = session();
let o = ok(
&mut s,
r#"
var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25);
var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1);
layer.transform.position.setValue([10, 20]);
layer.startTime = 1;
var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25);
layer.replaceSource(nested, false);
[layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem,
layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers]
"#,
);
assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1]));
let bad = run_code(
&mut s,
r#"
var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25);
var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1);
layer.replaceSource(app.project.items.addFolder("F"), false);
"#,
"replace-source.jsx",
);
assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}");
}
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
struct Flat;
impl effectcraft_engine::render::FootageSource for Flat {
@@ -1326,6 +1518,50 @@ fn key_interpolation_and_ease_apply_outside_the_active_comp() {
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
}
/// #1531: a key the user selected in the property's comp stays selected, and only the named key
/// changes (on the old code the change went to the selected key instead).
/// The active comp's own key selection is untouched.
#[test]
fn a_per_key_method_in_another_comp_changes_only_its_key_and_keeps_both_selections() {
let mut s = session();
let o = ok(
&mut s,
r#"
var C = app.project.items.addComp('C', 100, 100, 1, 10, 20);
var p = C.layers.addNull().transform.rotation;
p.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
var A = app.project.items.addComp('A', 100, 100, 1, 10, 20);
A.openInViewer();
var q = A.layers.addNull().transform.opacity;
q.setValuesAtTimes([0, 1], [0, 100]);
app.run('edit.deselectAll', {});
q.setSelectedAtKey(2, true);
// the user selected key 3 of C earlier: C remembers it while A is active
p.setSelectedAtKey(3, true);
p.setInterpolationTypeAtKey(1, KeyframeInterpolationType.HOLD);
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 80)]);
var hold = KeyframeInterpolationType.HOLD;
var out = [p.keyOutInterpolationType(1) == hold, p.keyOutInterpolationType(3) == hold, p.keyInInterpolationType(3) == hold,
p.keyInTemporalEase(2)[0].influence, p.keyInTemporalEase(3)[0].influence,
q.keySelected(1), q.keySelected(2)];
p.removeKey(1);
out.push(p.numKeys, p.keyTime(1), p.keyValue(1), q.keySelected(2), app.project.activeItem.name);
// C's remembered selection is live again once C is active: still the user's key (now 2)
C.openInViewer();
out.push(p.keySelected(1), p.keySelected(2));
out;
"#,
);
let v = o.result.as_array().unwrap();
let ease = |i: usize| v[i].as_f64().unwrap();
assert_eq!(&v[..3], &[json!(true), json!(false), json!(false)], "only key 1 holds: {v:?}");
assert!((ease(3) - 80.0).abs() < 1e-6, "key 2 eased: {v:?}");
assert!((ease(4) - 80.0).abs() > 1e-6, "key 3 untouched: {v:?}");
assert_eq!(&v[5..7], &[json!(false), json!(true)], "A's key selection kept: {v:?}");
assert_eq!(&v[7..12], &[json!(2), json!(1), json!(90), json!(true), json!("A")], "key 1 removed, A still active: {v:?}");
assert_eq!(&v[12..], &[json!(false), json!(true)], "C's key selection kept, the target key not added: {v:?}");
}
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
#[test]
fn source_point_conversions_follow_the_layer_transform() {
@@ -1503,3 +1739,68 @@ fn scripted_text_animator_property_is_the_one_asked_for() {
);
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
}
/// #1936: an array whose length doesn't match the property's dimension is an error in After
/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
#[test]
fn set_value_rejects_an_array_of_the_wrong_dimension() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
var before = "" + ramp.value;
var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
var tooFew = attempt(function () { ramp.setValue([1]); });
var untouched = "" + ramp.value;
ramp.setValue([7, 8]);
var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
// A 3-D property still takes two values, as it does in After Effects: z stays 0.
l.property("Position").setValue([120, 34]);
[tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
"#,
);
assert_eq!(
o.result,
json!([
"Array is not of the correct dimension",
"Array is not of the correct dimension",
true,
"7,8",
"Array is not of the correct dimension",
"120,34,0"
])
);
let (_, c) = comp_named(&s, "Dims");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
}
/// #1942: the scripting width and height setters resize the frame only. Re-anchoring the old frame
/// in the new one, which moves every unparented layer, belongs to the Composition Settings dialog.
#[test]
fn setting_comp_width_and_height_leaves_the_layers_where_they_are() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("w", 400, 200, 1, 1, 25);
var l = c.layers.addSolid([1, 1, 1], "s", 50, 50, 1, 1);
var pos = l.property("ADBE Transform Group").property("ADBE Position");
pos.setValue([100, 100]);
var k = c.layers.addSolid([1, 1, 1], "k", 50, 50, 1, 1);
var kpos = k.property("ADBE Transform Group").property("ADBE Position");
kpos.setValueAtTime(0, [10, 20]);
kpos.setValueAtTime(1, [30, 40]);
var before = "" + pos.value;
c.width = 800;
c.height = 400;
[before, "" + pos.value, "" + kpos.keyValue(1), "" + kpos.keyValue(2), c.width, c.height]
"#,
);
assert_eq!(o.result, json!(["100,100,0", "100,100,0", "10,20,0", "30,40,0", 800, 400]));
let (_, c) = comp_named(&s, "w");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0]));
}
+610
View File
@@ -0,0 +1,610 @@
//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity:
//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and
//! spatial tangents reported as they play, `selectedProperties` with keyed properties,
//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease
//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the
//! core panel of v0.6.0 saved.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_engine::project::{LayerId, Property};
use effectcraft_keyframe::{Ease, Interp, ease_progress};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::run_code;
const PANEL: &str = "Ease Presets.jsx";
fn session(store: &Arc<MemoryConfig>) -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
crate::install(&mut s);
s.config = Some(store.clone());
s.load_settings();
s
}
/// A comp with one solid; returns its id.
fn setup(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap();
}
}
fn prop(s: &Session, l: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn ok(s: &mut Session, code: &str) -> Value {
let out = run_code(s, code, "test.jsx");
assert!(out.error.is_none(), "{:?}", out.error);
out.result
}
fn at(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
#[test]
fn app_settings_last_between_runs() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#);
// A new run (the next launch) reads them back.
let mut s = session(&store);
let r = ok(
&mut s,
r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#,
);
assert_eq!(r, json!([true, "12", "Box", false]));
assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"}));
// A corrupt settings file loads as none, never an error.
store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap();
let mut s = session(&store);
assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false));
assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err());
}
#[test]
fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t});
s.execute("edit.deselectAll", json!({})).unwrap();
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
let before = s.state.selected_keys.clone();
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
// A linear side reports the speed it plays at (33.3 % influence).
var lin = p.keyInTemporalEase(3)[0];
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
// Keyed properties are selected properties, as in After Effects.
var sel = comp.selectedProperties;
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
);
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
assert_eq!(s.state.selected_keys, before);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 }));
assert_eq!(op.keys[2].in_interp, Interp::Hold);
// removeKey drops the removed key from the selection, keeps the rest.
ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)");
assert_eq!(s.state.selected_keys, before[..1]);
// Values that don't interpolate only hold.
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText;
[t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#,
);
assert_eq!(r, json!([false, true, true]));
}
#[test]
fn spatial_tangents_are_reported_as_the_path_plays() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([100, 100, 0]))]);
let pos = prop(&s, l, "transform/position");
let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1);
let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]");
for (got, want) in [(&r[0], inn), (&r[1], out)] {
for d in 0..2 {
assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}");
}
}
assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}");
assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays");
}
/// The panel's window and a control's description by name.
fn open_panel(s: &mut Session) -> u64 {
let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap();
r["window"].as_u64().unwrap()
}
fn control(s: &mut Session, win: u64, name: &str) -> Value {
fn find(w: &Value, name: &str) -> Option<Value> {
if w["name"] == name {
return Some(w.clone());
}
w["children"].as_array()?.iter().find_map(|c| find(c, name))
}
let w = s.execute("scriptui.get", json!({"window": win})).unwrap();
find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}"))
}
fn set(s: &mut Session, win: u64, widget: &str, value: Value) {
s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap();
}
fn click(s: &mut Session, win: u64, widget: &str) {
s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap();
}
fn status(s: &mut Session, win: u64) -> String {
control(s, win, "status")["text"].as_str().unwrap().to_string()
}
#[test]
fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap();
let selected = s.state.selected_keys.clone();
let win = open_panel(&mut s);
// It is a bundled extension listed with the ScriptUI panels.
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
// In-Out (the default) on every key: each pair gets the whole curve, as before.
assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}");
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
let eased = Ease { speed: 0.0, influence: 0.5 };
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased));
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, eased));
// The first key's in side and the last key's out side are not part of a pair.
assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear));
// A symmetric curve passes the middle value at mid-time, slow near the keys.
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6));
let quarter = op.value_at(at(1.5)).as_f64();
assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}");
assert_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier);
s.execute("edit.undo", json!({})).unwrap();
let op = prop(&s, l, "transform/opacity");
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
// Nothing selected: nothing changes, and the panel says why.
s.execute("edit.deselectAll", json!({})).unwrap();
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Linear"));
assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it");
assert_eq!(s.history.undo.len(), steps);
}
/// Select only the keys at `keys` (property path, time in seconds).
fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) {
s.execute("edit.deselectAll", json!({})).unwrap();
let keys: Vec<Value> = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
}
/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply.
fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
set(s, win, "mode", json!(mode));
click(s, win, "apply");
}
fn eased(e: Ease, speed: f64, influence: f64) -> bool {
close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9)
}
/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both,
/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its
/// neighbours, key times, values or the selection; one undo step restores it.
#[test]
fn one_selected_keyframe_eases_its_in_out_or_both_sides() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
let win = open_panel(&mut s);
// In-Out is the default.
assert_eq!(control(&mut s, win, "mode")["selection"], json!([2]));
let before = prop(&s, l, "transform/opacity");
for mode in ["In", "Out", "In-Out"] {
select_keys(&mut s, l, &[("transform/opacity", 1.0)]);
let selected = s.state.selected_keys.clone();
let steps = s.history.undo.len();
apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]);
assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve"));
assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step");
assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays");
let op = prop(&s, l, "transform/opacity");
assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change");
assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}");
let k = &op.keys[1];
let (ins, outs) = (mode != "Out", mode != "In");
assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}");
assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}");
if ins {
assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease);
} else {
assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly");
}
if outs {
assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease);
} else {
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone");
}
}
/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes
/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several
/// properties, each ease on their own.
#[test]
fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
let win = open_panel(&mut s);
let before = prop(&s, l, "transform/opacity");
let c = [30.0, 0.5, 60.0, 2.0];
for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] {
select_keys(&mut s, l, &[("transform/opacity", t)]);
let steps = s.history.undo.len();
apply_typed(&mut s, win, mode, c);
assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}"));
assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step");
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
}
// In-Out on the first key eases only its out side.
select_keys(&mut s, l, &[("transform/opacity", 0.0)]);
apply_typed(&mut s, win, "In-Out", c);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier));
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
assert_eq!(op.keys[1..], before.keys[1..]);
s.execute("edit.undo", json!({})).unwrap();
// The first and last Opacity keys (not neighbours) and the last Rotation key.
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]);
let steps = s.history.undo.len();
apply_typed(&mut s, win, "In-Out", c);
assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve");
assert_eq!(s.history.undo.len(), steps + 1);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease);
assert_eq!(op.keys[1], before.keys[1], "the unselected middle key");
let rot = prop(&s, l, "transform/rotation");
assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease);
assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
}
/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides
/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only
/// out sides.
#[test]
fn a_selected_pair_eases_the_sides_its_mode_names() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
let win = open_panel(&mut s);
let before = prop(&s, l, "transform/opacity");
let c = [30.0, 0.5, 60.0, 2.0];
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]);
apply_typed(&mut s, win, "In-Out", c);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6));
assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease);
assert_eq!(op.keys[2], before.keys[2]);
s.execute("edit.undo", json!({})).unwrap();
apply_typed(&mut s, win, "Out", c);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3));
assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear));
}
/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an
/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too.
#[test]
fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
ok(
&mut s,
r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType;
t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD);
t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
t.rotation.setTemporalAutoBezierAtKey(2, true);"#,
);
let rot = prop(&s, l, "transform/rotation");
let after: Vec<f64> = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect();
let win = open_panel(&mut s);
set(&mut s, win, "mode", json!("In"));
select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]);
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}");
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold));
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease);
assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds");
let eased_rot = prop(&s, l, "transform/rotation");
assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease);
for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) {
assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}");
}
assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed");
}
/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set
/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased.
#[test]
fn roving_keyframes_are_skipped_and_reported() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)");
let pos = prop(&s, l, "transform/position");
assert!(pos.keys[1].roving);
let roving_at = pos.keys[1].time.seconds();
let win = open_panel(&mut s);
// Only the roving key: nothing changes, no undo step.
select_keys(&mut s, l, &[("transform/position", roving_at)]);
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them");
assert_eq!(s.history.undo.len(), steps);
assert_eq!(prop(&s, l, "transform/position").keys, pos.keys);
// With an ordinary key: that one is eased, the roving one is counted as skipped.
select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]);
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(
status(&mut s, win),
"Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them"
);
let k = &prop(&s, l, "transform/position").keys[1];
assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}");
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5));
}
/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state.
#[test]
fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
ok(
&mut s,
r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType;
r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
r.setTemporalAutoBezierAtKey(2, true);"#,
);
let before = prop(&s, l, "transform/rotation");
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/rotation", 1.0)]);
apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]);
let after = prop(&s, l, "transform/rotation");
assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease);
for t in [0.25, 0.5, 0.75] {
assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}");
}
assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys);
}
/// On a curved path, a single key's in and out speeds follow the length of the path on each side
/// (unequal lengths and durations), and the side not eased plays as it did.
#[test]
fn a_single_key_on_a_curved_path_eases_along_each_side() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]);
let before = prop(&s, l, "transform/position");
let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1));
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/position", 1.0)]);
apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]);
let pos = prop(&s, l, "transform/position");
let k = &pos.keys[1];
// In speed: twice the incoming segment's average (its path length over 1 s).
assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease);
// The out side (2 s along a longer path) plays as before.
for t in [1.5, 2.0, 2.5] {
let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2());
assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}");
}
// Playback on the in side: arriving at twice the average speed, the last tenth of a second
// covers more of the path than a constant speed would (len_in / 10).
let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2());
let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt();
assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}");
assert!(len_out > len_in, "{len_out} {len_in}");
}
/// On one key, each side scales to its own segment: per dimension, and along the motion path.
#[test]
fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
// Scale: +100/−50 per second into the key, 0/+50 per second out of it.
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]);
// Position on a straight path: 100 px/s into the key, 50 px/s out of it.
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]);
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]);
apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]);
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve");
let k = prop(&s, l, "transform/scale").keys[1].clone();
assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease);
assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease);
let k = prop(&s, l, "transform/position").keys[1].clone();
assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease);
assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease);
let pos = prop(&s, l, "transform/position");
assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays");
}
#[test]
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (2.0, json!([300, 400, 0]))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
let win = open_panel(&mut s);
let typed = |s: &mut Session, c: [f64; 4]| {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
click(s, win, "apply");
};
typed(&mut s, [60.0, 0.0, 20.0, 2.0]);
let sc = prop(&s, l, "transform/scale");
// X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension.
assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]);
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
let v = sc.value_at(at(0.5)).as_vec2();
assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}");
// On Position the speed is along the 500 px path.
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
typed(&mut s, [25.0, 0.4, 75.0, 0.0]);
let pos = prop(&s, l, "transform/position");
assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease);
let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5);
let p = pos.value_at(at(1.0)).as_vec2();
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
// From Keys reads the applied curve back into the fields.
for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] {
set(&mut s, win, name, json!("1"));
}
click(&mut s, win, "fromKeys");
let fields: Vec<String> =
["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect();
assert_eq!(fields, ["25", "0.4", "75", "0"]);
}
#[test]
fn user_presets_save_rename_delete_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
let win = open_panel(&mut s);
set(&mut s, win, "presets", json!("Decelerate"));
// Save keeps the curve in the fields under the typed name.
set(&mut s, win, "name", json!(" Settle "));
click(&mut s, win, "save");
assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}");
// Built-in names are refused.
set(&mut s, win, "name", json!("linear"));
click(&mut s, win, "save");
assert!(status(&mut s, win).contains("is a built-in preset"));
let items = |s: &mut Session| control(s, win, "presets")["items"].clone();
assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle");
// Rename and Delete act on the selected user preset; built-in presets can't change.
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "name", json!("Straight"));
click(&mut s, win, "rename");
assert_eq!(status(&mut s, win), "Select one of your own presets first");
set(&mut s, win, "presets", json!("Settle"));
set(&mut s, win, "name", json!("Soft Landing"));
click(&mut s, win, "rename");
let names = items(&mut s);
assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}");
// Kept with app.settings: a new run of the panel (the next launch) lists them.
let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap();
let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap();
assert_eq!(presets["presets"][0]["name"], "Soft Landing");
assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}");
let mut s2 = session(&store);
setup(&mut s2);
let win2 = open_panel(&mut s2);
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13);
set(&mut s2, win2, "presets", json!("Soft Landing"));
click(&mut s2, win2, "delete");
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets");
}
/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the
/// panel's settings when settings load, once; unusable entries are skipped by the panel.
#[test]
fn presets_saved_by_the_old_core_panel_carry_over() {
let store = Arc::new(MemoryConfig::default());
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
let old = json!({"version": 1, "presets": [
{"name": "Snap", "curve": c},
{"name": "Linear", "curve": c},
{"name": "", "curve": c},
{"name": "No curve"},
{"name": "snap", "curve": c},
{"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}}
]});
store.write("ease_presets.json", &old.to_string()).unwrap();
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
let items = control(&mut s, win, "presets")["items"].clone();
assert_eq!(items.as_array().unwrap().len(), 13, "{items}");
assert_eq!(items[12], "Snap");
// Deleting it there is final: the old file doesn't come back.
set(&mut s, win, "presets", json!("Snap"));
click(&mut s, win, "delete");
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
}
-321
View File
@@ -1,321 +0,0 @@
//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity:
//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and
//! spatial tangents reported as they play, `selectedProperties` with keyed properties,
//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease
//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the
//! core panel of v0.6.0 saved.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_engine::project::{LayerId, Property};
use effectcraft_keyframe::{Ease, Interp, ease_progress};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::run_code;
const PANEL: &str = "Ease Presets.jsx";
fn session(store: &Arc<MemoryConfig>) -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
crate::install(&mut s);
s.config = Some(store.clone());
s.load_settings();
s
}
/// A comp with one solid; returns its id.
fn setup(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap();
}
}
fn prop(s: &Session, l: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn ok(s: &mut Session, code: &str) -> Value {
let out = run_code(s, code, "test.jsx");
assert!(out.error.is_none(), "{:?}", out.error);
out.result
}
fn at(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
#[test]
fn app_settings_last_between_runs() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#);
// A new run (the next launch) reads them back.
let mut s = session(&store);
let r = ok(
&mut s,
r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#,
);
assert_eq!(r, json!([true, "12", "Box", false]));
assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"}));
// A corrupt settings file loads as none, never an error.
store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap();
let mut s = session(&store);
assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false));
assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err());
}
#[test]
fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t});
s.execute("edit.deselectAll", json!({})).unwrap();
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
let before = s.state.selected_keys.clone();
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
// A linear side reports the speed it plays at (33.3 % influence).
var lin = p.keyInTemporalEase(3)[0];
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
// Keyed properties are selected properties, as in After Effects.
var sel = comp.selectedProperties;
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
);
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
assert_eq!(s.state.selected_keys, before);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 }));
assert_eq!(op.keys[2].in_interp, Interp::Hold);
// removeKey drops the removed key from the selection, keeps the rest.
ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)");
assert_eq!(s.state.selected_keys, before[..1]);
// Values that don't interpolate only hold.
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText;
[t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#,
);
assert_eq!(r, json!([false, true, true]));
}
#[test]
fn spatial_tangents_are_reported_as_the_path_plays() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([100, 100, 0]))]);
let pos = prop(&s, l, "transform/position");
let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1);
let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]");
for (got, want) in [(&r[0], inn), (&r[1], out)] {
for d in 0..2 {
assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}");
}
}
assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}");
assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays");
}
/// The panel's window and a control's description by name.
fn open_panel(s: &mut Session) -> u64 {
let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap();
r["window"].as_u64().unwrap()
}
fn control(s: &mut Session, win: u64, name: &str) -> Value {
fn find(w: &Value, name: &str) -> Option<Value> {
if w["name"] == name {
return Some(w.clone());
}
w["children"].as_array()?.iter().find_map(|c| find(c, name))
}
let w = s.execute("scriptui.get", json!({"window": win})).unwrap();
find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}"))
}
fn set(s: &mut Session, win: u64, widget: &str, value: Value) {
s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap();
}
fn click(s: &mut Session, win: u64, widget: &str) {
s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap();
}
fn status(s: &mut Session, win: u64) -> String {
control(s, win, "status")["text"].as_str().unwrap().to_string()
}
#[test]
fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap();
let selected = s.state.selected_keys.clone();
let win = open_panel(&mut s);
// It is a bundled extension listed with the ScriptUI panels.
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}");
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
let eased = Ease { speed: 0.0, influence: 0.5 };
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased));
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, eased));
// The first key's in side and the last key's out side are not part of a pair.
assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear));
// A symmetric curve passes the middle value at mid-time, slow near the keys.
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6));
let quarter = op.value_at(at(1.5)).as_f64();
assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}");
assert_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier);
s.execute("edit.undo", json!({})).unwrap();
let op = prop(&s, l, "transform/opacity");
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
// Nothing selected: nothing changes, and the panel says why.
s.execute("edit.deselectAll", json!({})).unwrap();
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Linear"));
assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property");
assert_eq!(s.history.undo.len(), steps);
}
#[test]
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (2.0, json!([300, 400, 0]))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
let win = open_panel(&mut s);
let typed = |s: &mut Session, c: [f64; 4]| {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
click(s, win, "apply");
};
typed(&mut s, [60.0, 0.0, 20.0, 2.0]);
let sc = prop(&s, l, "transform/scale");
// X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension.
assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]);
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
let v = sc.value_at(at(0.5)).as_vec2();
assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}");
// On Position the speed is along the 500 px path.
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
typed(&mut s, [25.0, 0.4, 75.0, 0.0]);
let pos = prop(&s, l, "transform/position");
assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease);
let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5);
let p = pos.value_at(at(1.0)).as_vec2();
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
// From Keys reads the applied curve back into the fields.
for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] {
set(&mut s, win, name, json!("1"));
}
click(&mut s, win, "fromKeys");
let fields: Vec<String> =
["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect();
assert_eq!(fields, ["25", "0.4", "75", "0"]);
}
#[test]
fn user_presets_save_rename_delete_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
let win = open_panel(&mut s);
set(&mut s, win, "presets", json!("Decelerate"));
// Save keeps the curve in the fields under the typed name.
set(&mut s, win, "name", json!(" Settle "));
click(&mut s, win, "save");
assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}");
// Built-in names are refused.
set(&mut s, win, "name", json!("linear"));
click(&mut s, win, "save");
assert!(status(&mut s, win).contains("is a built-in preset"));
let items = |s: &mut Session| control(s, win, "presets")["items"].clone();
assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle");
// Rename and Delete act on the selected user preset; built-in presets can't change.
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "name", json!("Straight"));
click(&mut s, win, "rename");
assert_eq!(status(&mut s, win), "Select one of your own presets first");
set(&mut s, win, "presets", json!("Settle"));
set(&mut s, win, "name", json!("Soft Landing"));
click(&mut s, win, "rename");
let names = items(&mut s);
assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}");
// Kept with app.settings: a new run of the panel (the next launch) lists them.
let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap();
let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap();
assert_eq!(presets["presets"][0]["name"], "Soft Landing");
assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}");
let mut s2 = session(&store);
setup(&mut s2);
let win2 = open_panel(&mut s2);
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13);
set(&mut s2, win2, "presets", json!("Soft Landing"));
click(&mut s2, win2, "delete");
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets");
}
/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the
/// panel's settings when settings load, once; unusable entries are skipped by the panel.
#[test]
fn presets_saved_by_the_old_core_panel_carry_over() {
let store = Arc::new(MemoryConfig::default());
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
let old = json!({"version": 1, "presets": [
{"name": "Snap", "curve": c},
{"name": "Linear", "curve": c},
{"name": "", "curve": c},
{"name": "No curve"},
{"name": "snap", "curve": c},
{"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}}
]});
store.write("ease_presets.json", &old.to_string()).unwrap();
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
let items = control(&mut s, win, "presets")["items"].clone();
assert_eq!(items.as_array().unwrap().len(), 13, "{items}");
assert_eq!(items[12], "Snap");
// Deleting it there is final: the old file doesn't come back.
set(&mut s, win, "presets", json!("Snap"));
click(&mut s, win, "delete");
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
}