mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge upstream main into fix/1063-text-animator-stacked
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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) }})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user