mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user