mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 22:20:00 -06:00
1063: use canonical text animator match names and resolve main
This commit is contained in:
@@ -106,5 +106,9 @@ 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;
|
||||
|
||||
@@ -90,6 +90,34 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> {
|
||||
|
||||
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
|
||||
|
||||
/// Animator property ids and their stable After Effects scripting names. Shared by lookup,
|
||||
/// `.matchName`, and `addProperty` so the same property has one name in every direction.
|
||||
pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[
|
||||
("anchor", "ADBE Text Anchor Point 3D"),
|
||||
("position", "ADBE Text Position 3D"),
|
||||
("scale", "ADBE Text Scale 3D"),
|
||||
("skew", "ADBE Text Skew"),
|
||||
("rotation", "ADBE Text Rotation"),
|
||||
("opacity", "ADBE Text Opacity"),
|
||||
("fillColor", "ADBE Text Fill Color"),
|
||||
("fillHue", "ADBE Text Fill Hue"),
|
||||
("fillSaturation", "ADBE Text Fill Saturation"),
|
||||
("fillBrightness", "ADBE Text Fill Brightness"),
|
||||
("fillOpacity", "ADBE Text Fill Opacity"),
|
||||
("strokeColor", "ADBE Text Stroke Color"),
|
||||
("strokeHue", "ADBE Text Stroke Hue"),
|
||||
("strokeSaturation", "ADBE Text Stroke Saturation"),
|
||||
("strokeBrightness", "ADBE Text Stroke Brightness"),
|
||||
("strokeOpacity", "ADBE Text Stroke Opacity"),
|
||||
("strokeWidth", "ADBE Text Stroke Width"),
|
||||
("tracking", "ADBE Text Tracking Amount"),
|
||||
("lineAnchor", "ADBE Text Line Anchor"),
|
||||
("lineSpacing", "ADBE Text Line Spacing"),
|
||||
("characterOffset", "ADBE Text Character Offset"),
|
||||
("characterValue", "ADBE Text Character Replace"),
|
||||
("blur", "ADBE Text Blur"),
|
||||
];
|
||||
|
||||
const TEXT_SELECTORS: &[(&str, &str)] =
|
||||
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
|
||||
|
||||
@@ -447,6 +475,9 @@ pub fn node(chain: &[&Node]) -> String {
|
||||
"text" if pid == "animators" => "ADBE Text Animator".into(),
|
||||
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => {
|
||||
find(TEXT_ANIMATOR_PROPERTIES, id).map(str::to_string).unwrap_or_else(own)
|
||||
}
|
||||
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
|
||||
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
|
||||
@@ -497,6 +528,17 @@ mod tests {
|
||||
assert_eq!(shape_kind("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_animator_property_names_are_canonical_and_scoped() {
|
||||
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
|
||||
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
|
||||
for (id, name) in TEXT_ANIMATOR_PROPERTIES {
|
||||
let prop = n(id);
|
||||
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
|
||||
assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_range_selector_names() {
|
||||
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
|
||||
|
||||
@@ -447,14 +447,11 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<
|
||||
matchnames::node(&ch)
|
||||
};
|
||||
let lk = loose(k);
|
||||
// An animator's Properties group also answers to the After Effects names of its properties.
|
||||
let animator_props = g.match_id == "properties";
|
||||
g.children
|
||||
.iter()
|
||||
.find(|n| chain_of(n) == k)
|
||||
.or_else(|| g.children.iter().find(|n| n.name() == k))
|
||||
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
|
||||
.or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k)))
|
||||
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
|
||||
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
|
||||
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
|
||||
@@ -742,50 +739,15 @@ fn resolve_add(s: &Session, a: &J) -> R {
|
||||
})
|
||||
}
|
||||
|
||||
/// The After Effects match name and key of each text animator property.
|
||||
fn animator_property_table() -> &'static [(&'static str, &'static str)] {
|
||||
&[
|
||||
("ADBE Text Anchor Point 3D", "anchor"),
|
||||
("ADBE Text Position 3D", "position"),
|
||||
("ADBE Text Scale 3D", "scale"),
|
||||
("ADBE Text Skew", "skew"),
|
||||
("ADBE Text Rotation", "rotation"),
|
||||
("ADBE Text Opacity", "opacity"),
|
||||
("ADBE Text Fill Color", "fillColor"),
|
||||
("ADBE Text Fill Hue", "fillHue"),
|
||||
("ADBE Text Fill Saturation", "fillSaturation"),
|
||||
("ADBE Text Fill Brightness", "fillBrightness"),
|
||||
("ADBE Text Fill Opacity", "fillOpacity"),
|
||||
("ADBE Text Stroke Color", "strokeColor"),
|
||||
("ADBE Text Stroke Hue", "strokeHue"),
|
||||
("ADBE Text Stroke Saturation", "strokeSaturation"),
|
||||
("ADBE Text Stroke Brightness", "strokeBrightness"),
|
||||
("ADBE Text Stroke Opacity", "strokeOpacity"),
|
||||
("ADBE Text Stroke Width", "strokeWidth"),
|
||||
("ADBE Text Tracking Amount", "tracking"),
|
||||
("ADBE Text Line Anchor", "lineAnchor"),
|
||||
("ADBE Text Line Spacing", "lineSpacing"),
|
||||
("ADBE Text Character Offset", "characterOffset"),
|
||||
("ADBE Text Character Replace", "characterValue"),
|
||||
("ADBE Text Blur", "blur"),
|
||||
]
|
||||
}
|
||||
|
||||
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
|
||||
/// name or a display name.
|
||||
fn text_animator_property(name: &str) -> Option<&'static str> {
|
||||
let table = animator_property_table();
|
||||
if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
|
||||
let table = matchnames::TEXT_ANIMATOR_PROPERTIES;
|
||||
if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) {
|
||||
return Some(k);
|
||||
}
|
||||
let n = loose(name);
|
||||
table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
|
||||
}
|
||||
|
||||
/// The After Effects match name of a text animator property key (`tracking` is
|
||||
/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063).
|
||||
fn animator_property_match_name(key: &str) -> Option<&'static str> {
|
||||
animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m)
|
||||
table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
|
||||
}
|
||||
|
||||
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
|
||||
|
||||
@@ -1977,6 +1977,7 @@ var app = {
|
||||
savePrefAsBool: function () {},
|
||||
savePrefAsFloat: function () {},
|
||||
reload: function () {},
|
||||
saveToDisk: function () {},
|
||||
},
|
||||
newProject: function () { __call("file.newProject", {}); return new Project(); },
|
||||
open: function (file) {
|
||||
|
||||
@@ -157,11 +157,16 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
s.state.selected_layers.clear();
|
||||
}
|
||||
let r = s.execute_for_script(id, params);
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
&& s.state.active_comp == Some(c)
|
||||
&& id != "comp.open"
|
||||
{
|
||||
if let Some(c) = target {
|
||||
leave_comp(s, id, c, prev);
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// After a script's command on comp `c`, make `prev` (the comp active before it) active again,
|
||||
/// unless the command opened `c` on purpose; with no comp to go back to, `c` stays open.
|
||||
fn leave_comp(s: &mut Session, id: &str, c: effectcraft_engine::project::ItemId, prev: Option<effectcraft_engine::project::ItemId>) {
|
||||
if prev != Some(c) && s.state.active_comp == Some(c) && id != "comp.open" {
|
||||
match prev {
|
||||
Some(p) if s.project.comp(p).is_some() => s.activate_comp(p),
|
||||
_ => {
|
||||
@@ -171,7 +176,6 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`,
|
||||
@@ -195,11 +199,17 @@ fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result<J,
|
||||
let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?;
|
||||
keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time });
|
||||
}
|
||||
// Make the keys' comp active before selecting them: activating a comp brings back the key
|
||||
// selection it had, which would replace these keys and leave the command nothing to act on
|
||||
// when the comp isn't the active one (#1531).
|
||||
let prev = s.state.active_comp;
|
||||
s.activate_comp(cid);
|
||||
let saved = std::mem::replace(&mut s.state.selected_keys, keys);
|
||||
let r = exec(s, id, params);
|
||||
let comp = s.active_comp();
|
||||
let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time));
|
||||
s.state.selected_keys = saved.into_iter().filter(exists).collect();
|
||||
leave_comp(s, id, cid, prev);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -336,9 +346,19 @@ pub(crate) fn normalize(p: &str) -> std::path::PathBuf {
|
||||
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
|
||||
std::fs::canonicalize(&out)
|
||||
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
|
||||
.map(without_verbatim_prefix)
|
||||
.unwrap_or(out)
|
||||
}
|
||||
|
||||
/// A canonical Windows drive path without its `\\?\` prefix, so it compares with a lexical one
|
||||
/// (a path whose folder doesn't exist on disk stays lexical).
|
||||
fn without_verbatim_prefix(p: std::path::PathBuf) -> std::path::PathBuf {
|
||||
match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||
Some(plain) if plain.as_bytes().get(1) == Some(&b':') => plain.into(),
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
|
||||
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
|
||||
@@ -380,11 +400,14 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
}
|
||||
let s = &a.session;
|
||||
match op.as_str() {
|
||||
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
|
||||
// Through the session's services, like the reads: hosts without a file system (the
|
||||
// browser's file table) answer for their own files.
|
||||
"access" => Ok(json!(s.services.exists(&check_access(s, &path, false)?.to_string_lossy()))),
|
||||
"exists" | "isDir" => {
|
||||
// Unreadable locations report "missing" instead of failing.
|
||||
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
|
||||
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
|
||||
let p = p.to_string_lossy();
|
||||
Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) }))
|
||||
}
|
||||
"read" | "readBinary" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
@@ -563,27 +586,32 @@ 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()?))
|
||||
};
|
||||
// The innermost frame in the user's script, or the error's own position.
|
||||
let frames: Vec<&str> = msg.lines().skip(1).collect();
|
||||
let frame = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick));
|
||||
// `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).
|
||||
// the name, so the name's first use from that frame's line on is the better position (#1063).
|
||||
// With no frame to start from, the first use anywhere in the code is (#1161).
|
||||
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)) {
|
||||
let start_line = frame.map_or(1, |(line, _)| line);
|
||||
if let Some(at) = undefined.and_then(|name| first_use(name, code, start_line)) {
|
||||
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)) {
|
||||
if let Some((a, b)) = frame {
|
||||
return (Some(a), Some(b));
|
||||
}
|
||||
(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)> {
|
||||
/// The first use of `name` as a whole identifier in `code` on or after `start_line` (1-based), as a
|
||||
/// 1-based line and column.
|
||||
fn first_use(name: &str, code: &str, start_line: u32) -> 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() {
|
||||
for (n, line) in code.lines().enumerate().skip(start_line.saturating_sub(1) as usize) {
|
||||
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) {
|
||||
@@ -829,4 +857,14 @@ mod nesting_tests {
|
||||
assert_eq!(out.result, serde_json::json!(2));
|
||||
assert!(check_nesting(&("(".repeat(MAX_NESTING) + &")".repeat(MAX_NESTING))).is_ok());
|
||||
}
|
||||
|
||||
/// A canonical drive path and a lexical one compare once the `\\?\` prefix is gone; a UNC
|
||||
/// share and a Unix path are left as they are.
|
||||
#[test]
|
||||
fn only_a_verbatim_drive_prefix_is_dropped() {
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(without_verbatim_prefix(r"\\?\C:\proj\a.txt".into()), PathBuf::from(r"C:\proj\a.txt"));
|
||||
assert_eq!(without_verbatim_prefix(r"\\?\UNC\server\share\a.txt".into()), PathBuf::from(r"\\?\UNC\server\share\a.txt"));
|
||||
assert_eq!(without_verbatim_prefix("/proj/a.txt".into()), PathBuf::from("/proj/a.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
+110
-2
@@ -187,6 +187,18 @@ fn errors_inside_a_function_wrapper_report_the_failing_line() {
|
||||
assert_eq!(e.line, Some(3), "{e:?}");
|
||||
}
|
||||
|
||||
/// The first use of an undefined name is the one inside the function that reads it: a mention
|
||||
/// before it, in a comment, a string or another function, doesn't move the report (#1161).
|
||||
#[test]
|
||||
fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function() {
|
||||
let mut s = session();
|
||||
let code = "// nope is mentioned here\nvar text = 'nope';\nfunction other() { return nope; }\n(function () {\n var a = 1;\n nope.call();\n})();\n";
|
||||
let o = run_code(&mut s, code, "earlier.jsx");
|
||||
let e = o.error.expect("reference error");
|
||||
assert!(e.message.contains("nope"), "{e:?}");
|
||||
assert_eq!(e.line, Some(6), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_names_and_lookups() {
|
||||
let mut s = session();
|
||||
@@ -430,6 +442,32 @@ fn save_frame_to_png_writes_behind_the_file_gate() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still
|
||||
/// gets the question; only the user's answer opens the file.
|
||||
#[test]
|
||||
fn a_script_cannot_confirm_executing_a_file_for_itself() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-exec-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("run.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let q = serde_json::to_string(&f.to_string_lossy()).unwrap();
|
||||
let mut s = session();
|
||||
// The user allows file access; the warning stays on (the default).
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, effectcraft_engine::Event::OpenUrl(_)));
|
||||
for code in [
|
||||
format!("JSON.stringify(app.run('file.executeFile', {{path: {q}, confirmed: true}}))"),
|
||||
format!("JSON.stringify(app.run('engine.batch', {{steps: [{{command: 'file.executeFile', params: {{path: {q}, confirmed: true}}}}]}}))"),
|
||||
format!("new File({q}).execute()"),
|
||||
] {
|
||||
let o = ok(&mut s, &code);
|
||||
assert!(!o.result.to_string().contains("opened") || o.result == json!(false), "{code}: {:?}", o.result);
|
||||
assert!(!opened(&mut s), "{code} opened the file without asking");
|
||||
}
|
||||
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]
|
||||
@@ -1258,6 +1296,36 @@ fn effect_parameter_interpolation_and_ease() {
|
||||
assert_eq!(o.result, json!([true, true, 16.67, 33.33, true]));
|
||||
}
|
||||
|
||||
/// #1531: the per-key methods act on a comp that isn't the active one too. Activating the keys'
|
||||
/// comp brought back its own (empty) key selection over the keys named, so the interpolation and
|
||||
/// the ease were silently dropped once another comp with a layer had been made first.
|
||||
#[test]
|
||||
fn key_interpolation_and_ease_apply_outside_the_active_comp() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var a = app.project.items.addComp("A", 400, 400, 1, 10, 20); a.layers.addNull();
|
||||
var c = app.project.items.addComp("C", 400, 400, 1, 10, 20);
|
||||
var n = c.layers.addNull(); n.threeDLayer = true;
|
||||
var p = n.property("ADBE Transform Group").property("ADBE Rotate Y");
|
||||
p.setValueAtTime(1, 0); p.setValueAtTime(2, -178);
|
||||
for (var j = 1; j <= 2; j++) {
|
||||
p.setInterpolationTypeAtKey(j, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
|
||||
p.setTemporalEaseAtKey(j, [new KeyframeEase(0, 60)], [new KeyframeEase(0, 60)]);
|
||||
}
|
||||
[p.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER,
|
||||
p.keyInInterpolationType(2) == KeyframeInterpolationType.BEZIER,
|
||||
p.keyOutTemporalEase(1)[0].influence,
|
||||
Math.round(p.valueAtTime(1.25, false) * 100) / 100]
|
||||
"#,
|
||||
);
|
||||
let v = o.result;
|
||||
assert_eq!((&v[0], &v[1], &v[2]), (&json!(true), &json!(true), &json!(60)), "{v}");
|
||||
// Eased, not the linear -44.5.
|
||||
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
|
||||
}
|
||||
|
||||
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
|
||||
#[test]
|
||||
fn source_point_conversions_follow_the_layer_transform() {
|
||||
@@ -1376,6 +1444,45 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() {
|
||||
assert_eq!(o.result, json!([0.5, 0.5]));
|
||||
}
|
||||
|
||||
/// After Effects resolves the Transform properties and Source Text from the layer itself, without
|
||||
/// naming their group: `layer.property("Position")` (#1348).
|
||||
#[test]
|
||||
fn layer_property_resolves_transform_and_source_text_shortcuts() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("x", 100, 100, 1, 5, 30);
|
||||
var t = c.layers.addText("hi");
|
||||
var s = c.layers.addSolid([1, 0, 0], "s", 100, 100, 1);
|
||||
var listed = [t.property("Source Text"), s.property("Position"), s.property("Opacity"), s.property("Scale")].join(", ");
|
||||
s.property("Position").setValue([30, 40]);
|
||||
s.property("Opacity").setValue(25);
|
||||
t.property("Source Text").setValue("bye");
|
||||
s.threeDLayer = true;
|
||||
[listed, s.property("Position").value[0], s.property("Opacity").value, t.property("Source Text").value.text,
|
||||
s.property("ADBE Position").matchName, s("Anchor Point").matchName, s.property("Rotation").matchName,
|
||||
s.property("X Rotation").matchName, s.property("Orientation").matchName]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"[object Property], [object Property], [object Property], [object Property]",
|
||||
30,
|
||||
25,
|
||||
"bye",
|
||||
"ADBE Position",
|
||||
"ADBE Anchor Point",
|
||||
"ADBE Rotate Z",
|
||||
"ADBE Rotate X",
|
||||
"ADBE Orientation"
|
||||
])
|
||||
);
|
||||
let (_, c) = comp_named(&s, "x");
|
||||
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
|
||||
}
|
||||
|
||||
/// A text animator property added by its After Effects name is the one asked for, not its Tracking
|
||||
/// Type companion, and it can be found again by that name (#1063).
|
||||
#[test]
|
||||
@@ -1390,8 +1497,9 @@ fn scripted_text_animator_property_is_the_one_asked_for() {
|
||||
var props = a.property("ADBE Text Animator Properties");
|
||||
var added = props.addProperty("ADBE Text Tracking Amount");
|
||||
added.setValue(20);
|
||||
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name]
|
||||
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name,
|
||||
added.matchName, props.property(added.matchName).propertyIndex]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"]));
|
||||
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
|
||||
}
|
||||
|
||||
@@ -94,3 +94,10 @@ fn calling_without_a_name_is_a_script_error() {
|
||||
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
|
||||
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_preferences_save_to_disk_is_callable() {
|
||||
let mut s = session();
|
||||
let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]");
|
||||
assert_eq!(o.result, json!(["function", 0]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Scripts see the host's files: existence checks go through `Services`, like reads, so a host
|
||||
//! that keeps files in memory (the web editor's browser storage) answers for its own files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use effectcraft_engine::{Services, Session};
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mem(Mutex<HashMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for Mem {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(path)
|
||||
.cloned()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), data.into());
|
||||
Ok(())
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
|
||||
}
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
path == "/browser/footage"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_opens_a_file_only_the_host_holds() {
|
||||
let mem = Arc::new(Mem::default());
|
||||
mem.write_file("/browser/data.txt", b"hello").unwrap();
|
||||
let mut s = Session { services: mem, ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
// A project in a folder that isn't on disk: scripts may read files next to it.
|
||||
s.path = Some("/browser/project.ecproj".into());
|
||||
let out = run_code(
|
||||
&mut s,
|
||||
r#"var f = new File("/browser/data.txt"); var ok = f.open("r");
|
||||
writeLn(ok + " " + (ok ? f.read() : f.error));
|
||||
writeLn(new File("/browser/missing.txt").exists + " " + new Folder("/browser/footage").exists);"#,
|
||||
"t.jsx",
|
||||
);
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
assert_eq!(out.output, vec!["true hello".to_string(), "false true".to_string()]);
|
||||
}
|
||||
Reference in New Issue
Block a user