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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian Myram
2026-10-13 21:56:33 +02:00
co-authored by Claude Sonnet 5.5
220 changed files with 18858 additions and 1542 deletions
+19
View File
@@ -72,6 +72,22 @@ const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[
("lastMargin", "ADBE Text Last Margin"),
];
// A stroke's Dashes, as After Effects' scripting API names them (#815).
const STROKE_DASHES: &[(&str, &str)] = &[
("dash", "ADBE Vector Stroke Dash 1"),
("gap", "ADBE Vector Stroke Gap 1"),
("dash2", "ADBE Vector Stroke Dash 2"),
("gap2", "ADBE Vector Stroke Gap 2"),
("dash3", "ADBE Vector Stroke Dash 3"),
("gap3", "ADBE Vector Stroke Gap 3"),
("offset", "ADBE Vector Stroke Offset"),
];
/// The Dashes child id (`dash`, `gap2`, `offset`…) for an After Effects match name.
pub fn stroke_dash(name: &str) -> Option<&'static str> {
STROKE_DASHES.iter().find(|(_, m)| *m == name).map(|(id, _)| *id)
}
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
const TEXT_SELECTORS: &[(&str, &str)] =
@@ -447,6 +463,9 @@ pub fn node(chain: &[&Node]) -> String {
if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) {
return m.to_string();
}
if pid == "dashes" {
return find(STROKE_DASHES, id).map(str::to_string).unwrap_or_else(own);
}
own()
}
_ => own(),
+90 -22
View File
@@ -44,6 +44,7 @@ pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
"textdoc" => textdoc(s, a),
"markers" => markers(s, a),
"sourceRect" => source_rect(s, a),
"pointConvert" => point_convert(s, a),
"resolveAdd" => resolve_add(s, a),
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
"commandAt" => {
@@ -93,6 +94,27 @@ fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
/// Layer ids selected in `comp`, including a comp that is not the active one (#1001).
fn selected_layer_ids(s: &Session, comp: effectcraft_project::ItemId) -> Vec<u64> {
let layers = if s.active_comp_id() == Some(comp) {
&s.state.selected_layers
} else {
match s.state.comp_selections.get(&comp) {
Some(sel) => &sel.layers,
None => return Vec::new(),
}
};
let Some(c) = s.project.comp(comp) else { return Vec::new() };
layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect()
}
fn layer_selected(s: &Session, comp: effectcraft_project::ItemId, layer: effectcraft_project::LayerId) -> bool {
if s.active_comp_id() == Some(comp) {
return s.state.selected_layers.contains(&layer);
}
s.state.comp_selections.get(&comp).is_some_and(|sel| sel.layers.contains(&layer))
}
/// A comp's selected properties as `[layer, uid]`: the selected ones, then those with selected
/// keyframes (selecting a keyframe selects its property in After Effects, so scripts find
/// selected keys through `selectedProperties`).
@@ -121,6 +143,11 @@ fn item(s: &Session, a: &J) -> R {
});
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
o["usedIn"] = json!(used_in);
// The item's proxy (footage and comps): its file and whether Use Proxy is on (#813).
o["proxy"] = match &it.proxy {
Some(p) => json!({"file": p.footage.path, "isStill": p.footage.kind == effectcraft_project::FootageKind::Still, "enabled": p.enabled}),
None => J::Null,
};
match &it.kind {
ItemKind::Folder => {
o["kind"] = json!("folder");
@@ -129,7 +156,7 @@ fn item(s: &Session, a: &J) -> R {
ItemKind::Comp(c) => {
let cid = id;
o["kind"] = json!("comp");
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
let sel = selected_layer_ids(s, cid);
let fps = c.frame_rate.as_f64();
if let Some(m) = o.as_object_mut() {
m.extend(
@@ -171,6 +198,9 @@ fn item(s: &Session, a: &J) -> R {
o["footageMissing"] = json!(fo.missing);
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
o["loop"] = json!(fo.loop_count);
// Interpret Footage ▸ Conform to frame rate: 0 when the file's own rate is used (#813).
o["conformFrameRate"] = json!(if fo.native_rate.is_some() { fo.frame_rate.as_f64() } else { 0.0 });
o["nativeFrameRate"] = json!(fo.native_rate.unwrap_or(fo.frame_rate).as_f64());
}
ItemKind::Solid(so) => {
o["kind"] = json!("solid");
@@ -237,7 +267,7 @@ fn layer(s: &Session, a: &J) -> R {
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
"label": label_index(l.label),
"comment": l.comment,
"selected": s.state.selected_layers.contains(&l.id),
"selected": layer_selected(s, cid, l.id),
"autoOrient": format!("{:?}", l.auto_orient),
"width": w, "height": h,
"hasVideo": l.source.is_av(),
@@ -393,6 +423,8 @@ fn alias(k: &str) -> Option<&'static str> {
"content" | "contents" => "contents",
"maskshape" | "maskpath" => "path",
"maskfeather" => "feather",
// A light's options name it "ADBE Casts Shadows" in After Effects (#1063).
"adbecastsshadows" => "castsShadows",
"maskopacity" => "opacity",
"maskexpansion" | "maskoffset" => "expansion",
"sourcetext" => "sourceText",
@@ -488,6 +520,9 @@ fn value(s: &Session, a: &J) -> R {
} else {
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
// Sample expressions (`sampleImage`) read footage through the session's source; without
// it footage layers sample as transparent (#1000).
ctx.footage = Some(s.footage.as_ref());
ctx.value(l, p)
};
Ok(js_value(c, &v))
@@ -522,27 +557,28 @@ fn keys(s: &Session, a: &J) -> R {
.collect();
J::Array(v)
};
let out: Vec<J> = p
.keys
.iter()
.enumerate()
.map(|(i, k)| {
// Spatial tangents as the motion path plays (auto-Bezier and continuous keys too).
let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) };
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(i, false), "outEase": ease(i, true),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": spatial_in, "spatialOut": spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
let key = |i: usize, k: &effectcraft_keyframe::Keyframe| -> J {
// Spatial tangents as the motion path plays (auto-Bezier and continuous keys too).
let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) };
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(i, false), "outEase": ease(i, true),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": spatial_in, "spatialOut": spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
.collect();
Ok(J::Array(out))
};
// `index` (zero-based): only that key, as `{count, key}` (`key` null when out of range), so
// looking keys up one at a time doesn't serialise the whole list each time (#814).
if let Some(i) = u(a, "index") {
let k = usize::try_from(i).ok().and_then(|i| p.keys.get(i).map(|k| key(i, k)));
return Ok(json!({"count": p.keys.len(), "key": k}));
}
Ok(J::Array(p.keys.iter().enumerate().map(|(i, k)| key(i, k)).collect()))
}
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
@@ -597,6 +633,29 @@ fn markers(s: &Session, a: &J) -> R {
Ok(J::Array(out))
}
/// A layer's source point in comp space (`toComp`) or the reverse, through the layer's transform
/// and its parents' at comp time (#808: `sourcePointToComp`, `compPointToSource`).
fn point_convert(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let p = a.get("point").and_then(J::as_array).ok_or("missing point")?;
let x = p.first().and_then(J::as_f64).unwrap_or(0.0);
let y = p.get(1).and_then(J::as_f64).unwrap_or(0.0);
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
let (layer_space, _) = ctx.layer_to_comp(l);
let m = if a.get("toComp").and_then(J::as_bool).unwrap_or(true) {
layer_space
} else {
layer_space.inverse().ok_or("the layer's transform can't be inverted")?
};
let k = m.0;
let w = k[2][0] * x + k[2][1] * y + k[2][2];
let (ox, oy) = (k[0][0] * x + k[0][1] * y + k[0][2], k[1][0] * x + k[1][1] * y + k[1][2]);
let (ox, oy) = if w != 0.0 && w.is_finite() { (ox / w, oy / w) } else { (ox, oy) };
Ok(json!([ox, oy]))
}
fn source_rect(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
@@ -646,6 +705,15 @@ fn resolve_add(s: &Session, a: &J) -> R {
}
}
"animators" => json!({"kind": "animator"}),
// A stroke always holds Dash 1, Gap 1 and Offset (Dash 0 draws no dashes); adding one of
// them returns it, Dash/Gap 2 and 3 are added as a pair (`shape.dashes.add`) (#815).
"dashes" if in_group => match matchnames::stroke_dash(name) {
Some(id) => match g.children.iter().find(|n| n.match_id() == id) {
Some(n) => json!({"kind": "existing", "uid": n.uid()}),
None => json!({"kind": "dash", "id": id, "stroke": l.props.parent_of(g.uid).map(|p| p.uid)}),
},
None => json!({"error": format!("Can not add \"{name}\" to Dashes")}),
},
_ if in_contents && in_group => match matchnames::shape_kind(name) {
Some(k) => {
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
+74 -10
View File
@@ -715,6 +715,15 @@ PropertyGroup.prototype.addProperty = function (name) {
var p = { comp: L.__comp, layer: L.__id, kind: r.shape };
if (r.group !== null && r.group !== undefined) p.group = r.group;
uid = __call("layer.addShapeItem", p).uid;
} else if (r.kind === "existing") {
uid = r.uid;
} else if (r.kind === "dash") {
// Dash/Gap 2 and 3 come as a pair: add pairs until the one asked for exists.
var dp = { comp: L.__comp, layer: L.__id, prop: r.stroke };
for (var n = 0; n < 3 && !this.property(name); n++) __call("shape.dashes.add", dp);
var added = this.property(name);
if (!added) throw __err("Can not add \"" + name + "\" to Dashes");
return added;
} else if (r.kind === "animator") {
uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator;
} else if (r.kind === "animatorProperty") {
@@ -827,10 +836,11 @@ Property.prototype.property = function () { return null; };
};
P.__keys = function () { return __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); };
P.__key = function (i) {
var k = this.__keys();
i = __num(i, "keyIndex");
if (i < 1 || i > k.length || Math.floor(i) !== i) throw __err("keyIndex " + i + " is out of range 1.." + k.length);
return k[i - 1];
// 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));
return r.key;
};
// Script value → engine JSON for `prop.set` / `prop.addKey`.
P.__toEngine = function (v, info, time) {
@@ -1284,8 +1294,12 @@ AVLayer.prototype.constructor = AVLayer;
P.sourceRectAtTime = function (t, extents) {
return __get("sourceRect", { comp: this.__comp, layer: this.__id, time: __num(t, "time"), extents: !!extents });
};
P.sourcePointToComp = function (p) { return p; };
P.compPointToSource = function (p) { return p; };
// Source ↔ comp points, through the layer's transform and its parents' at the comp's time (#808).
function pointIn(layer, p, toComp) {
return __get("pointConvert", { comp: layer.__comp, layer: layer.__id, point: [__num(p[0], "point"), __num(p[1], "point")], toComp: toComp, time: layer.containingComp.time });
}
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"); };
P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; };
})();
@@ -1303,6 +1317,14 @@ Object.defineProperty(ShapeLayer.prototype, "source", { get: function () { retur
function CameraLayer(comp, id) { return this.__init(comp, id, "CameraLayer"); }
CameraLayer.prototype = Object.create(Layer.prototype);
CameraLayer.prototype.constructor = CameraLayer;
// Camera scripts (Google Earth Studio exports among them) key `cameraLayer.position`,
// `.xRotation` and `.zoom` directly, as on AVLayer: return the live Property objects.
["pointOfInterest", "position", "orientation", "xRotation", "yRotation", "zRotation"].forEach(function (name) {
Object.defineProperty(CameraLayer.prototype, name, { get: function () { return this.transform[name]; } });
});
["zoom", "depthOfField", "focusDistance", "aperture", "blurLevel"].forEach(function (name) {
Object.defineProperty(CameraLayer.prototype, name, { get: function () { return this.cameraOptions[name]; } });
});
function LightLayer(comp, id) { return this.__init(comp, id, "LightLayer"); }
LightLayer.prototype = Object.create(Layer.prototype);
@@ -1489,8 +1511,14 @@ 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 () { return false; });
def("proxySource", function () { return null; });
def("useProxy", function () { var p = this.__info().proxy; return !!(p && p.enabled); }, function (v) {
if (!this.__info().proxy) throw __err("\"" + this.name + "\" has no proxy");
__call("file.useProxy", { item: this.__id, on: !!v });
});
def("proxySource", function () {
var p = this.__info().proxy;
return p ? { isStill: !!p.isStill, file: p.file ? new File(p.file) : null, hasAlpha: false, toString: function () { return "[object FileSource]"; } } : null;
});
def("isMediaReplacementCompatible", function () { return false; });
def("time", function () { return 0; });
})();
@@ -1599,6 +1627,14 @@ CompItem.prototype.constructor = CompItem;
__call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) });
return String(name);
};
// After Effects' CompItem.saveFrameToPng(time, file): Composition ▸ Save Frame As ▸ File at
// `time` (comp seconds), behind the scripting file gate like other writes (#816).
P.saveFrameToPng = function (time, file) {
var f = __str(file);
if (!f) throw __err("saveFrameToPng: missing file");
__file("write", f, "");
__call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") });
};
P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); };
P.exportAsMotionGraphicsTemplate = function (overwrite, path) {
var f = __str(path);
@@ -1622,14 +1658,42 @@ FootageItem.prototype.constructor = FootageItem;
Object.defineProperty(FootageItem.prototype, "file", {
get: function () { var f = this.__info().file; return f ? new File(f) : null; },
});
// The item's FootageSource: one live object per FootageItem, as in After Effects (#813).
function FootageSource(item) { this.__item = item; }
FootageSource.prototype = {
constructor: FootageSource,
__info: function () { return this.__item.__info(); },
get isStill() { return !!this.__info().isStill; },
get color() { return this.__info().color; },
get file() { var i = this.__info(); return i.kind !== "solid" && i.file ? new File(i.file) : null; },
get missingFootagePath() { var i = this.__info(); return i.footageMissing ? i.file : ""; },
get loop() { return this.__info().loop; },
get hasAlpha() { return false; },
get nativeFrameRate() { return this.__info().nativeFrameRate || 0; },
get displayFrameRate() { return this.__info().frameRate; },
// Interpret Footage ▸ Conform to frame rate; 0 uses the file's own rate.
get conformFrameRate() { return this.__info().conformFrameRate || 0; },
set conformFrameRate(v) {
v = __num(v, "conformFrameRate");
if (v < 0) throw __err("conformFrameRate must be 0 or a frame rate");
__call("file.interpretFootage", { items: [this.__item.__id], frameRate: v === 0 ? "file" : v });
},
toString: function () { return this.__info().kind === "solid" ? "[object SolidSource]" : "[object FileSource]"; },
};
Object.defineProperty(FootageItem.prototype, "mainSource", {
get: function () {
var i = this.__info();
if (i.kind === "solid") return { isStill: true, color: i.color, hasAlpha: false, toString: function () { return "[object SolidSource]"; } };
return { isStill: !!i.isStill, file: i.file ? new File(i.file) : null, missingFootagePath: i.footageMissing ? i.file : "", loop: i.loop, hasAlpha: false, toString: function () { return "[object FileSource]"; } };
if (!this.__mainSource) Object.defineProperty(this, "__mainSource", { value: new FootageSource(this) });
return this.__mainSource;
},
});
FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); };
FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) {
__call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical });
};
// Proxies (footage and comps). A numbered still brings its sequence, so the two setters agree.
AVItem.prototype.setProxy = function (f) { __call("file.setProxy", { item: this.__id, path: __str(f) }); };
AVItem.prototype.setProxyWithSequence = function (f) { __call("file.setProxy", { item: this.__id, path: __str(f) }); };
AVItem.prototype.setProxyToNone = function () { __call("file.setProxyNone", { item: this.__id }); };
FootageItem.prototype.replaceWithSolid = function (color, name, w, h) {
__call("file.replaceWithSolid", { item: this.__id, color: [color[0], color[1], color[2]], width: w, height: h });
if (name !== undefined) this.name = name;
+31 -20
View File
@@ -151,7 +151,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
if let Some(c) = target
&& prev != Some(c)
{
s.state.active_comp = Some(c);
s.activate_comp(c);
}
if CREATES_LAYER.contains(&id) {
s.state.selected_layers.clear();
@@ -163,7 +163,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
&& id != "comp.open"
{
match prev {
Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p),
Some(p) if s.project.comp(p).is_some() => s.activate_comp(p),
_ => {
if !s.state.open_comps.contains(&c) {
s.state.open_comps.push(c);
@@ -547,9 +547,9 @@ pub(crate) fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &st
ScriptError { message, line, column, file: file.to_string() }
}
/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace
/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of
/// the undefined name in `code`.
/// Line and column of an error: `at line N, col M` (syntax errors), else the first use of an
/// undefined name in `code` (boa places those at a function's start), else the innermost backtrace
/// frame in the user's script (`file:line:col`).
fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
if let Some(i) = msg.find("at line ") {
let rest = &msg[i + 8..];
@@ -563,28 +563,39 @@ fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
let mut it = l[i..].split([':', ')']);
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
};
// `ReferenceError: name is not defined`: boa places it at the start of the function that reads
// the name, so the first use of the name in the code is the better position (#1063).
let first = msg.lines().next().unwrap_or("");
let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next());
if let Some(at) = undefined.and_then(|name| first_use(name, code)) {
return (Some(at.0), Some(at.1));
}
let frames: Vec<&str> = msg.lines().skip(1).collect();
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
return (Some(a), Some(b));
}
// `ReferenceError: name is not defined` without a position.
let first = msg.lines().next().unwrap_or("");
if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) {
for (n, line) in code.lines().enumerate() {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line[from..].find(name).map(|i| i + from) {
let before = i.checked_sub(1).map(|j| b[j]);
let after = b.get(i + name.len()).copied();
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
return (Some(n as u32 + 1), Some(i as u32 + 1));
}
from = i + name.len();
(None, None)
}
/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column.
fn first_use(name: &str, code: &str) -> Option<(u32, u32)> {
if name.is_empty() {
return None;
}
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
for (n, line) in code.lines().enumerate() {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) {
let before = i.checked_sub(1).and_then(|j| b.get(j).copied());
let after = b.get(i + name.len()).copied();
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
return Some((n as u32 + 1, i as u32 + 1));
}
from = i + name.len();
}
}
(None, None)
None
}
/// Evaluate `code` in `ctx` and convert the result.
+347
View File
@@ -31,6 +31,33 @@ fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"))
}
/// #1063 (item 8): Source Text retains CR while sourceRectAtTime sees both lines.
#[test]
fn carriage_return_source_text_has_multiline_script_bounds() {
let mut s = session();
let result = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Line breaks", 640, 360, 1, 1, 30);
var crDoc = new TextDocument("Line 1\rLine 2");
crDoc.font = "Inter";
crDoc.fontSize = 40;
var lfDoc = new TextDocument("Line 1\nLine 2");
lfDoc.font = "Inter";
lfDoc.fontSize = 40;
var cr = comp.layers.addText(crDoc);
var lf = comp.layers.addText(lfDoc);
var crRect = cr.sourceRectAtTime(0, false);
var lfRect = lf.sourceRectAtTime(0, false);
[cr.text.sourceText.value.text, crRect.height, lfRect.height, crRect.width, lfRect.width]
"#,
);
assert_eq!(result.result[0], "Line 1\rLine 2");
assert!(result.result[1].as_f64().unwrap() > 40.0);
assert_eq!(result.result[1], result.result[2]);
assert_eq!(result.result[3], result.result[4]);
}
#[test]
fn builds_a_comp_end_to_end() {
let mut s = session();
@@ -142,6 +169,24 @@ fn errors_report_line_and_column() {
assert_eq!(e.line, Some(3), "{e:?}");
}
/// A script wrapped in a function (the usual `(function () { ... })();` shape) reports the line
/// that failed, not the wrapper's line (#1063).
#[test]
fn errors_inside_a_function_wrapper_report_the_failing_line() {
let mut s = session();
let o = run_code(&mut s, "(function () {\n var a = 1;\n throw new Error('inside');\n})();\n", "thrown.jsx");
let e = o.error.expect("thrown");
assert_eq!(e.message, "inside");
assert_eq!(e.line, Some(3), "{e:?}");
let o = run_code(&mut s, "(function () {\n var a = 1;\n nope.call();\n})();\n", "reference.jsx");
let e = o.error.expect("reference error");
assert_eq!(e.line, Some(3), "{e:?}");
let o = run_code(&mut s, "(function () {\n var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n c.layer(5);\n})();\n", "range.jsx");
let e = o.error.expect("range error");
assert!(e.message.contains("out of range"), "{e:?}");
assert_eq!(e.line, Some(3), "{e:?}");
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
@@ -362,6 +407,29 @@ fn security_gate_for_files() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #816: CompItem.saveFrameToPng writes the frame as a PNG, behind the file-write preference.
#[test]
fn save_frame_to_png_writes_behind_the_file_gate() {
let dir = std::env::temp_dir().join(format!("ec-save-frame-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("frame.png");
let mut s = session();
let code = format!(
"var c = app.project.items.addComp('F', 40, 30, 1, 2, 25); c.layers.addSolid([1, 0, 0], 'S', 40, 30, 1); c.saveFrameToPng(1, new File({}))",
serde_json::to_string(&png.to_string_lossy()).unwrap()
);
let o = run_code(&mut s, &code, "frame.jsx");
assert!(o.error.expect("refused").message.contains("Allow Scripts to Write Files"));
assert!(!png.exists() || std::fs::metadata(&png).unwrap().len() == 0);
s.prefs.scripting.allow_scripts_write_files = true;
ok(&mut s, &code);
let bytes = std::fs::read(&png).unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
// IHDR width and height.
assert_eq!((u32::from_be_bytes(bytes[16..20].try_into().unwrap()), u32::from_be_bytes(bytes[20..24].try_into().unwrap())), (40, 30));
let _ = std::fs::remove_dir_all(&dir);
}
/// #590: a script can't give itself file and network access (or turn off the warning before
/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can.
#[test]
@@ -698,6 +766,36 @@ fn group_selected_false_deselects_masks_and_effects() {
assert_eq!(out.result, json!([[true, true], [false, true], [false, false]]));
}
/// A layer selected by script in a comp that is not open stays selected for the next script:
/// every comp keeps its own layer selection (#1001).
#[test]
fn layer_selection_in_a_closed_comp_survives_to_the_next_script() {
let mut s = session();
let out = ok(
&mut s,
r#"
var a = app.project.items.addComp("Active", 400, 300, 1, 2, 25);
a.openInViewer();
var c = app.project.items.addComp("Other", 400, 300, 1, 2, 25);
c.layers.addText("A");
c.layers.addText("B");
c.layer("B").selected = true;
c.selectedLayers.length + " " + c.layer("B").selected + " " + a.selectedLayers.length
"#,
);
assert_eq!(out.result, json!("1 true 0"));
// A second script call, the way a panel talks to the app over the control channel.
let out = ok(
&mut s,
r#"
var c = null;
for (var k = 1; k <= app.project.numItems; k++) { if (app.project.item(k).name === "Other") c = app.project.item(k); }
c.selectedLayers.length + " " + c.layer("B").selected
"#,
);
assert_eq!(out.result, json!("1 true"));
}
/// Probes every file as a 64x32 still (the media layer's single-file probe).
struct Stills;
impl effectcraft_engine::Importer for Stills {
@@ -731,6 +829,79 @@ fn import_options_sequence_picks_one_still_or_the_run() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #813: FootageItem proxies, replaceWithSequence and mainSource.conformFrameRate.
#[test]
fn footage_proxies_sequences_and_conform_rate() {
use effectcraft_engine::project::ItemKind;
let dir = std::env::temp_dir().join(format!("ec-script-proxy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for n in ["shot_0001.png", "shot_0002.png", "take_0001.png", "take_0002.png", "proxy.png"] {
std::fs::write(dir.join(n), b"").unwrap();
}
let p = |n: &str| dir.join(n).to_string_lossy().replace('\\', "/");
let mut s = session();
s.importer = Some(Arc::new(Stills));
let o = ok(
&mut s,
&format!(
"var io = new ImportOptions(new File('{}')); io.sequence = true; var item = app.project.importFile(io);\
var same = item.mainSource === item.mainSource;\
item.mainSource.conformFrameRate = 12; var c12 = item.mainSource.conformFrameRate, fr = item.frameRate;\
item.mainSource.conformFrameRate = 0; var c0 = item.mainSource.conformFrameRate;\
var before = [item.useProxy, item.proxySource];\
item.setProxy(new File('{}')); var set = [item.useProxy, item.proxySource.file.name];\
item.useProxy = false; var off = item.useProxy;\
item.setProxyToNone(); var none = item.proxySource;\
item.replaceWithSequence(new File('{}'), false);\
[same, c12, fr, c0, before[0], before[1], set[0], set[1], off, none]",
p("shot_0001.png"),
p("proxy.png"),
p("take_0001.png")
),
);
assert_eq!(o.result, json!([true, 12, 12, 0, false, null, true, "proxy.png", false, null]));
let paths: Vec<String> =
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect();
assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
struct Flat;
impl effectcraft_engine::render::FootageSource for Flat {
fn frame(
&self,
_item: effectcraft_engine::project::ItemId,
f: &effectcraft_engine::project::Footage,
_t: effectcraft_engine::time::Tick,
) -> Option<Arc<effectcraft_engine::render::Image>> {
Some(Arc::new(effectcraft_engine::render::Image::filled(f.width.max(1), f.height.max(1), [0.0, 1.0, 0.0, 1.0])))
}
}
/// `sampleImage` on a footage layer reads the footage's pixels from a script, not transparent
/// black: the expression evaluator needs the session's footage source (#1000).
#[test]
fn sample_image_reads_footage_pixels_in_scripts() {
let mut s = session();
s.footage = Arc::new(Flat);
s.importer = Some(Arc::new(Stills));
let out = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Scene", 640, 360, 1, 1, 25);
comp.openInViewer();
var img = comp.layers.add(app.project.importFile(new ImportOptions(new File("/tmp/ec-flat.png"))));
img.name = "Img";
var cc = comp.layers.addNull().property("ADBE Effect Parade").addProperty("ADBE Color Control").property(1);
cc.expression = 'thisComp.layer("Img").sampleImage([32, 16], [0.5, 0.5], true, time);';
var v = cc.valueAtTime(0, false);
[v[0], v[1], v[2], v[3], cc.expressionError];
"#,
);
assert_eq!(out.result, json!([0, 1, 0, 1, ""]), "green footage sampled, not transparent black");
}
#[test]
fn binary_file_io_preserves_all_byte_values() {
let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));
@@ -828,6 +999,55 @@ fn script_remove_keys_batches_a_range_as_one_edit() {
assert!(invalid.error.is_some());
}
/// #814: one key is looked up without serialising the whole list (removeKey on a property with
/// thousands of keys was linear in the key count), and the key calls behave as before.
#[test]
fn script_key_lookup_fetches_only_that_key() {
let mut s = session();
let o = ok(
&mut s,
"var c = app.project.items.addComp('Keys', 100, 100, 1, 100, 25); var p = c.layers.addNull().transform.rotation;\
var ts = [], vs = []; for (var i = 0; i < 2000; i++) { ts.push(i / 25); vs.push(i); } p.setValuesAtTimes(ts, vs);\
var q = { comp: p.__layer.__comp, layer: p.__layer.__id, uid: p.__uid };\
q.index = 2; var one = __get('keys', q); q.index = 2000; var none = __get('keys', q);\
p.removeKey(p.numKeys); p.removeKey(1);\
var err = ''; try { p.keyTime(1999); } catch (e) { err = e.message; }\
[one.count, one.key.time, none.key, p.numKeys, p.keyTime(1), p.keyValue(1998), err]",
);
assert_eq!(o.result, json!([2000, 0.08, null, 1998, 0.04, 1998, "keyIndex 1999 is out of range 1..1998"]));
}
/// #815: a stroke's Dashes use After Effects' match names, and addProperty with them returns
/// Dash 1 / Gap 1 / Offset or adds the Dash 2 / Gap 2 pair.
#[test]
fn script_stroke_dashes_use_after_effects_match_names() {
let mut s = session();
let o = ok(
&mut s,
"function names(g) { var a = []; for (var i = 1; i <= g.numProperties; i++) a.push(g.property(i).matchName); return a.join(','); }\
var comp = app.project.items.addComp('Names', 100, 100, 1, 10, 25);\
var contents = comp.layers.addShape().property('ADBE Root Vectors Group'); contents.addProperty('ADBE Vector Shape - Rect');\
var d = contents.addProperty('ADBE Vector Graphic - Stroke').property('ADBE Vector Stroke Dashes');\
var before = names(d);\
var dash1 = d.addProperty('ADBE Vector Stroke Dash 1'); dash1.setValue(12);\
var gap2 = d.addProperty('ADBE Vector Stroke Gap 2');\
var err = ''; try { d.addProperty('ADBE Vector Stroke Dash 9'); } catch (e) { err = e.message; }\
[before, dash1.matchName, d.property('ADBE Vector Stroke Dash 1').value, d.property('ADBE Vector Stroke Offset').matchName, gap2.matchName, names(d), err]",
);
assert_eq!(
o.result,
json!([
"ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Offset",
"ADBE Vector Stroke Dash 1",
12,
"ADBE Vector Stroke Offset",
"ADBE Vector Stroke Gap 2",
"ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Dash 2,ADBE Vector Stroke Gap 2,ADBE Vector Stroke Offset",
"Can not add \"ADBE Vector Stroke Dash 9\" to Dashes"
])
);
}
#[test]
fn script_layer_comment_and_unnamed_marker_params_round_trip() {
let mut s = session();
@@ -1011,6 +1231,133 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() {
assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"]));
}
/// `setInterpolationTypeAtKey` and `setTemporalEaseAtKey` (`keys.interpolation` / `keys.velocity`)
/// act on effect parameters too, not just transform properties (#994).
#[test]
fn effect_parameter_interpolation_and_ease() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("E", 100, 100, 1, 5, 25);
var l = c.layers.addNull();
var slider = l.property("ADBE Effect Parade").addProperty("ADBE Slider Control").property(1);
slider.setValuesAtTimes([0, 1, 2], [0, 50, 100]);
slider.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
slider.setTemporalEaseAtKey(2, [new KeyframeEase(0, 16.67)], [new KeyframeEase(0, 33.33)]);
var point = l.property("ADBE Effect Parade").addProperty("ADBE Point3D Control").property(1);
point.setValuesAtTimes([0, 1], [[0, 0, 0], [50, 50, 50]]);
point.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
[slider.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER,
slider.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER,
slider.keyInTemporalEase(2)[0].influence,
slider.keyOutTemporalEase(2)[0].influence,
point.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER];
"#,
);
assert_eq!(o.result, json!([true, true, 16.67, 33.33, true]));
}
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
#[test]
fn source_point_conversions_follow_the_layer_transform() {
let mut s = session();
let o = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25);
var layer = comp.layers.addSolid([1, 0, 0], "S", 200, 100, 1);
layer.transform.position.setValue([400, 300]);
layer.transform.scale.setValue([50, 50]);
var a = layer.sourcePointToComp([0, 0]);
var b = layer.compPointToSource([400, 300]);
[a[0], a[1], b[0], b[1]]
"#,
);
let v = o.result;
let near = |x: &Value, want: f64| x.as_f64().is_some_and(|g| (g - want).abs() < 1e-6);
assert!(near(&v[0], 350.0) && near(&v[1], 275.0), "{v}");
assert!(near(&v[2], 100.0) && near(&v[3], 50.0), "{v}");
}
/// #1063: a script asks a light's options for After Effects' "ADBE Casts Shadows".
#[test]
fn light_options_accept_the_after_effects_casts_shadows_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25);
var light = comp.layers.addLight("Key", [100, 100]);
var c = light.property("ADBE Light Options Group").property("ADBE Casts Shadows");
c == null ? "null" : c.name
"#,
);
assert_ne!(o.result, json!("null"), "{:?}", o.result);
}
/// A Google Earth Studio camera export: transform and camera shortcuts on the camera layer, keys
/// set through them, and Convert Expression to Keyframes run on the selected property (#1077).
#[test]
fn earth_studio_style_script_keys_the_camera() {
let mut s = session();
let o = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Orbit", 1920, 1080, 1, 1, 10);
comp.openInViewer();
var cam = comp.layers.addCamera("Camera", ["960", "540"]);
cam.locked = true;
var pos = cam.position, rx = cam.xRotation, ry = cam.yRotation, rz = cam.zRotation, zoom = cam.zoom;
cam.autoOrient = AutoOrientType.NO_AUTO_ORIENT;
var ints = comp.layers.addNull();
ints.name = "ints";
ints.threeDLayer = true;
ints.position.setValuesAtTimes([0, 0.5], [[100, 200, 300], [110, 220, 330]]);
rx.setValuesAtTimes([0, 0.5], [181, 182]);
ry.setValuesAtTimes([0, 0.5], [39, 38]);
rz.setValuesAtTimes([0, 0.5], [87, 86]);
pos.expression = 'thisComp.layer("ints").transform.position';
var id = app.findMenuCommandId("Convert Expression to Keyframes");
if (id && pos.canSetExpression && pos.expressionEnabled) {
pos.selected = true;
app.executeCommand(id);
pos.selected = false;
ints.remove();
}
zoom.setValueAtTime(0, 1500);
zoom.setValueAtTime(0.5, 1600);
[pos.numKeys, rx.numKeys, ry.numKeys, rz.numKeys, zoom.numKeys, comp.numLayers,
cam.pointOfInterest.matchName, cam.orientation.matchName, cam.depthOfField.matchName,
cam.focusDistance.matchName, cam.aperture.matchName, cam.blurLevel.matchName]
"#,
);
assert_eq!(
o.result,
json!([
10,
2,
2,
2,
2,
1,
"ADBE Anchor Point",
"ADBE Orientation",
"ADBE Camera Depth of Field",
"ADBE Camera Focus Distance",
"ADBE Camera Aperture",
"ADBE Camera Blur Level"
])
);
let (_, c) = comp_named(&s, "Orbit");
let cam = layer(c, "Camera");
let p = cam.props.prop("transform/position").unwrap();
assert_eq!(p.keys[5].value, KV::Vec3([110.0, 220.0, 330.0]));
assert!(!p.expr.as_ref().unwrap().enabled);
assert_eq!(cam.props.prop("transform/rotationX").unwrap().keys[1].value, KV::Scalar(182.0));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().keys[1].value, KV::Scalar(1600.0));
}
/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their
/// name on the effect as well as through the group (#1063).
#[test]
+1 -1
View File
@@ -39,7 +39,7 @@ fn includes_are_inlined_relative_to_the_script() {
write(&dir.join("vendor/extra.jsx"), "var extra = 40;");
write(
&dir.join("main.jsx"),
"#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('/').pop()]",
"#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('\\\\').pop().split('/').pop()]",
);
let mut s = session();
let o = run_file(&mut s, &dir.join("main.jsx"));