Merge main into #2452; resolve crates/engine/src/cmd/animation.rs crates/engine/src/tests/effects.rs

This commit is contained in:
bflatastic
2026-10-30 01:24:52 +07:00
456 changed files with 25361 additions and 6751 deletions
+8 -2
View File
@@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
}
// ISO 8601: date [T| ] time [zone].
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
let (y, m, d) = (s.get(0..4)?.parse::<i64>().ok()?, s.get(5..7)?.parse::<i64>().ok()?, s.get(8..10)?.parse::<i64>().ok()?);
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
let rest = s.get(10..)?.trim_start_matches(['T', 't', ' ']);
if !rest.is_empty() {
// Zone: Z or ±hh:mm / ±hhmm.
let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) {
@@ -229,6 +229,12 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use super::*;
#[test]
fn multibyte_date_is_rejected() {
assert_eq!(parse_stamp(&json!("2026-05-0é"), 25.0), None);
assert_eq!(parse_stamp(&json!("2026-05-01é"), 25.0), None);
}
#[test]
fn parses_timestamps() {
let p = |v: Value| parse_stamp(&v, 25.0).unwrap();
+1
View File
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
clip.push(KeyClip { layer: lid, path, keys });
}
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
s.state.key_clipboard = clip;
s.state.clip_is_keys = true;
Ok(json!(n))
+29 -1
View File
@@ -110,6 +110,21 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
}
/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
/// parameter id when it names a parameter that the effect has since renamed or moved into a
/// twirl-down group. Any other path comes back unchanged.
fn upgrade_effect_path(path: &str) -> String {
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
let effect = match seg.rsplit_once('#') {
Some((m, n)) if !m.is_empty() && n.parse::<usize>().is_ok() => m,
_ => seg,
};
match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
Some(id) if id != tail => format!("effects/{seg}/{id}"),
_ => path.to_string(),
}
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
(Some(v), _) => v.clone(),
@@ -147,7 +162,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
.map(|a| {
a.iter()
.filter_map(|v| {
let path = v["path"].as_str()?.to_string();
let path = upgrade_effect_path(v["path"].as_str()?);
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
@@ -160,16 +175,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
})
.unwrap_or_default();
let t = s.time();
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let sizes: BTreeMap<LayerId, [f64; 2]> =
ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
let mut applied = 0;
let mut skipped = vec![];
s.edit("Apply Animation Preset", None, |proj, _| {
let mut next = proj.next_id;
for lid in &ids {
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(fx) = l.props.sub_mut("effects") {
for g in &effects {
let mut g = g.clone();
// Effects saved by an earlier version get the upgrade a project gets on open.
let spec = match &g.kind {
GroupKind::Effect { effect } => effectcraft_effects::find(effect),
_ => None,
};
if let Some(spec) = spec {
effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
}
retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
g.reassign_uids(&mut next);
next += 1;
+7
View File
@@ -263,6 +263,7 @@ fn clear_clipboards(s: &mut Session) {
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.value_clipboard.clear();
s.state.keyframe_text = None;
s.state.clip_is_keys = false;
}
@@ -329,10 +330,12 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
}
let mut keys = s.state.selected_keys.clone();
let mut values = vec![];
let mut statics = vec![];
for (lid, uid) in &leaves {
let Some(l) = comp.layer(*lid) else { continue };
let Some(pr) = l.props.find(*uid) else { continue };
if pr.keys.is_empty() {
statics.push((*lid, *uid));
if let Some(path) = l.props.match_path_of(*uid)
&& !values.iter().any(|(p, _)| *p == path)
{
@@ -342,6 +345,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
}
}
let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics);
clear_clipboards(s);
let mut n = 0;
if !keys.is_empty() {
@@ -350,6 +354,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
s.state.selected_keys = selected;
n = r?.as_u64().unwrap_or(0);
}
s.state.keyframe_text = text;
let v = values.len();
s.state.value_clipboard = values;
// Keys alone answer as `keys.copy` does (their count).
@@ -847,10 +852,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
"bytes": st.bytes,
"maxBytes": st.max_bytes,
"limitBytes": st.limit_bytes,
"hits": st.hits,
"misses": st.misses,
"writes": st.writes,
"evictions": st.evictions,
"failed": st.failed,
}))
}
+311 -25
View File
@@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
struct CryptoHit {
at: (effectcraft_project::ItemId, LayerId, Tick),
probe: std::sync::Arc<CryptoProbe>,
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
hash: u32,
@@ -210,13 +211,135 @@ impl CryptoHit {
}
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution).
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect
/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render
/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a
/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]).
pub struct CryptoProbe {
key: ProbeKey,
grid: std::result::Result<ProbeGrid, String>,
}
#[derive(Clone)]
struct ProbeGrid {
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
scale: f64,
offset: [f64; 2],
}
/// What a probe depends on: the project revision, the effect (and its index), the frame, the
/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows).
#[derive(Clone, Copy, PartialEq, Debug)]
struct ProbeKey {
revision: u64,
at: (effectcraft_project::ItemId, LayerId, Uid, usize),
frame: ProbeFrame,
scale: f64,
prefs: (bool, bool),
}
/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect
/// don't change over time, its source frame: a still has one, held or remapped frames share
/// theirs. Otherwise the comp time.
#[derive(Clone, Copy, PartialEq, Debug)]
enum ProbeFrame {
Source(i64),
Comp(Tick),
}
/// The session's kept probe, and the generation of the latest request: a build stores its
/// result only while no newer request or edit came since.
#[derive(Default)]
pub struct CryptoProbeSlot {
probe: Option<std::sync::Arc<CryptoProbe>>,
want: Option<ProbeKey>,
generation: u64,
/// Jobs run (each on its thread), and those that built (not cancelled before they began).
jobs: u64,
builds: u64,
}
impl CryptoProbeSlot {
/// Drop the kept probe and cancel builds in flight.
pub(crate) fn clear(&mut self) {
(self.probe, self.want) = (None, None);
self.generation += 1;
}
pub(crate) fn kept(&self) -> bool {
self.probe.is_some()
}
pub(crate) fn builds(&self) -> (u64, u64) {
(self.jobs, self.builds)
}
}
/// A probe built off the UI thread into the session's slot.
pub type CryptoProbeJob = Box<dyn FnOnce() + Send>;
const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)";
impl CryptoProbe {
/// `None` when `live` turns false between its stages (a newer request or an edit since):
/// a cancelled build stops early and lets go of what it read.
fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option<CryptoProbe> {
let (cid, lid, _, pos) = key.at;
let build = || -> std::result::Result<Option<ProbeGrid>, String> {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
if !live() {
return Ok(None);
}
let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
// up, proxies have their own size, earlier effects can pad it), rendered and cached.
if !live() {
return Ok(None);
}
let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?;
Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset }))
};
// On another thread no command guard runs: a panic becomes the probe's error.
let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string()));
Some(CryptoProbe { key, grid: grid.transpose()? })
}
}
/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression).
fn moves(g: &PropGroup, skip: Option<Uid>) -> bool {
g.children.iter().any(|c| match c {
effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()),
effectcraft_project::Node::Group(g) => moves(g, skip),
})
}
/// [`ProbeFrame`] of the effect at index `pos` of `layer`.
fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame {
let comp = ProbeFrame::Comp(ctx.time);
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp };
let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp };
let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1));
let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time."));
let still = f.kind == effectcraft_project::FootageKind::Still;
let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off;
// The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it.
let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None };
if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) {
return comp;
}
ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 })
}
/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp
/// time, its layer, group and Selection property.
#[allow(clippy::type_complexity)]
fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> {
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let scale = f_p(p, "scale").unwrap_or(1.0);
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
@@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
let aux = r.layer_aux(&ctx, layer);
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop))
}
/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken
/// before an undo, redo or history jump).
pub(crate) fn kept_crypto_probe(s: &Session) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision)
}
/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the
/// probe from before, still answers when only Cryptomatte Selections changed, which the probe
/// doesn't read (undoing a pick, as the pick itself keeps it).
pub(crate) fn keep_crypto_probe(s: &Session, kept: Option<std::sync::Arc<CryptoProbe>>, before: &effectcraft_project::Project) {
let Some(p) = kept else { return };
if !only_crypto_selections_differ(before, &s.project) {
return;
}
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() }));
}
}
/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects.
fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool {
let mut x = a.clone();
for (cid, it) in x.items.iter_mut() {
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
let sels: Vec<(LayerId, Uid)> = c
.layers
.iter()
.flat_map(|l| {
l.effects()
.into_iter()
.flat_map(|fx| fx.groups())
.filter(|g| g.match_id == "ec.3d.cryptomatte")
.filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid)))
})
.collect();
if sels.is_empty() {
continue;
}
let Some(bc) = b.comp(*cid) else { return false };
let c = std::sync::Arc::make_mut(c);
for (lid, uid) in sels {
let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false };
if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) {
*dst = src.clone();
}
}
}
x.same_content(b)
}
/// The session's probe when it has `key`'s.
fn cached_probe(s: &Session, key: ProbeKey) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key)
}
/// [`Session::crypto_probe_ready`].
pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result<bool> {
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
Ok(cached_probe(s, key).is_some())
}
/// [`Session::crypto_probe_job`].
pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoProbeJob>> {
let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
let generation = {
let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?;
if slot.probe.as_ref().is_some_and(|p| p.key == key) {
return Ok(None);
}
// A request for another probe cancels the one in flight.
if slot.want != Some(key) {
(slot.want, slot.generation) = (Some(key), slot.generation + 1);
}
slot.generation
};
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
// proxies have their own size, earlier effects can pad it), rendered and cached.
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (project, footage, expr, cache, accel, slot) =
(s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone());
let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
// The slot while this is still the newest request.
fn current(slot: &std::sync::Mutex<CryptoProbeSlot>, generation: u64) -> Option<std::sync::MutexGuard<'_, CryptoProbeSlot>> {
slot.lock().ok().filter(|s| s.generation == generation)
}
Ok(Some(Box::new(move || {
match slot.lock() {
Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1),
Ok(mut s) => return s.jobs += 1,
Err(_) => return,
}
let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts);
r.expr = expr.as_deref();
r.cache = Some(&cache);
r.accel = accel.as_deref();
// Checked between its stages too: a superseded build stops at the next one.
let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return };
let probe = std::sync::Arc::new(probe);
// Only the newest request's result, and never over a newer probe.
if let Some(mut s) = current(&slot, generation)
&& s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision)
{
s.probe = Some(probe);
}
})))
}
/// What a pick finds at a point.
enum Hit {
/// `probeOnly` and the probe isn't built yet.
Pending,
Nothing,
Id(CryptoHit),
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`.
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let (key, time, layer, g, prop) = probe_key(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let probe = match cached_probe(s, key) {
Some(probe) => probe,
None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending),
None => {
let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.clear();
slot.builds += 1;
}
// (Never cancelled here: nothing else runs on this thread.)
let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) };
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.probe = Some(probe.clone());
}
probe
}
};
let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?;
let (bs, o) = (b.scale, b.offset);
// The centre of its pixel under the point, at the aux point the effect reads there.
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
}
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly`
/// it answers `{pending: true}` instead.
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? {
Hit::Pending => json!({"pending": true}),
Hit::Nothing => Value::Null,
Hit::Id(h) => h.reply(&h.selection),
})
}
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
@@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
"remove" => PickMode::Remove,
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
};
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
let h = match crypto_hit(s, p, cmd)? {
Hit::Id(h) => h,
Hit::Pending => return Ok(json!({"pending": true, "changed": false})),
Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")),
};
if h.driven {
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
}
@@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
let (cid, lid, t) = h.at;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
// The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not
// "Change Property" like any other edit. (A changed Selection is always a new step.)
if let Some(step) = s.history.undo.last_mut() {
step.0 = "Pick Cryptomatte".into();
}
// Only the Selection changed, which the probe doesn't read: the next hover keeps it.
// (The edit dropped it; a probe built since is newer.)
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
let key = ProbeKey { revision: s.revision, ..h.probe.key };
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() }));
}
}
let mut out = h.reply(&sel);
out["selection"] = json!(sel);
@@ -519,6 +804,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.keyframe_text = None;
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
@@ -732,14 +1018,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Cryptomatte",
[],
None,
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point",
has_layers,
pick_crypto
),
crate::query!(
"effect.cryptomatteAt",
"Cryptomatte At",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point",
crypto_at
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Result, Session, Value};
// ---------- enablement ----------
+36 -5
View File
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
// File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
if let Some(i) = p.get("recent") {
let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
Some(x) => x.clone(),
None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
};
let q = match script {
crate::prefs::RecentScript::Path(path) => json!({"path": path}),
crate::prefs::RecentScript::Name(name) => json!({"name": name}),
};
return run_script(s, &q);
}
// File ▸ Scripts ▸ <installed or sample script>.
if let Some(name) = str_p(p, "name")
&& p.get("path").is_none()
@@ -446,15 +460,22 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let full = s.services.full_path(path);
let code = String::from_utf8_lossy(&bytes).to_string();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let out = run_js(s, &code, path, false)?;
return report_js(s, &name, out);
let result = report_js(s, &name, out)?;
if let Some(full) = full {
remember_script(s, crate::prefs::RecentScript::Path(full));
}
return Ok(result);
}
let mut script_path = None;
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
(Some(Value::Array(a)), _) => a.clone(),
(_, Some(path)) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
script_path = s.services.full_path(path);
let text = String::from_utf8_lossy(&bytes).to_string();
match serde_json::from_str::<Value>(&text) {
Ok(Value::Array(a)) => a,
@@ -487,9 +508,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
results.push(r);
}
if let Some(path) = script_path {
remember_script(s, crate::prefs::RecentScript::Path(path));
}
Ok(json!({"steps": results.len(), "results": results}))
}
/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
s.prefs.push_recent_script(script);
s.save_prefs();
s.prefs_revision += 1;
}
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
/// command scripts.
fn is_js_script(path: &str) -> bool {
@@ -1017,7 +1048,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Script File...",
["File", "Scripts"],
None,
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
always,
run_script
),
@@ -1035,7 +1066,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
has_footage_selection,
interpret
),
@@ -1053,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Interpretation",
["File", "Interpret Footage"],
Some("Cmd+Alt+V"),
"{items?}",
"{items?|item?}",
has_interpretation,
apply_interpretation
),
@@ -1084,7 +1115,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_footage_selection,
replace_with_solid
),
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload),
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
]
}
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{apply_text_params, text_range_p};
use super::*;
use super::{
EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut,
layer_p, merge_p, str_p,
};
pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad,
build, comp_id, f_p, json, limits, str_p,
};
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
+2 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
@@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod parent_chain_tests {
use super::super::json;
use super::*;
use std::sync::Arc;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p,
unlocked,
};
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer};
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
layers_p, merge_p, resolve_layer, str_p,
};
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
resolve_layer, str_p, unlocked, with,
};
pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{color_p, pixel_aspect_p};
use super::*;
use super::{
EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits,
str_p, unlocked,
};
/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well
/// as the combined property the channels were split from.
+36 -7
View File
@@ -102,11 +102,39 @@ fn quote(s: &str) -> String {
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
reference_with(comp, from, target, uid, compact, false)
}
/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an
/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The
/// effect keeps its name. An index is the property's place among its group's children, as an
/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place.
pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option<String> {
let chain = target.props.node_chain(uid)?;
// Each node's one-based index among its group's children, and whether it lies inside an
// effect (below the effect itself): those are selected by index with `by_index`.
let mut parent = &target.props;
let mut depth_in_effects: Option<usize> = None;
let mut by_number: Vec<Option<usize>> = vec![];
for n in &chain {
let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1);
by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1)));
depth_in_effects = match depth_in_effects {
Some(d) => Some(d + 1),
None => (n.match_id() == "effects").then_some(0),
};
if let Node::Group(g) = n {
parent = g;
}
}
let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() {
Some(at) => format!("({at})"),
None => format!("({})", quote(label)),
};
let mut parts: Vec<String> = vec![];
let mut pending: Option<&str> = None;
let mut in_effect = false;
for n in chain {
for (i, n) in chain.iter().copied().enumerate() {
if let Some(f) = pending.take() {
parts.push(format!("{f}({})", quote(n.name())));
in_effect |= f == "effect";
@@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
(Node::Group(_), "transform") if !in_effect => parts.push("transform".into()),
(Node::Group(_), "text") if !in_effect => parts.push("text".into()),
(Node::Prop(_), "sourceText") => parts.push("sourceText".into()),
_ if in_effect => parts.push(format!("({})", quote(n.name()))),
_ if in_effect => parts.push(selector(i, n.name())),
_ => parts.push(camel(n.name())),
}
}
@@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com
let _ = comp;
if !compact {
// Match names: language-independent, like After Effects with Compact English off.
let chain = target.props.node_chain(uid)?;
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
for n in chain {
out.push_str(&format!("({})", quote(n.match_id())));
for (i, n) in chain.iter().copied().enumerate() {
out.push_str(&selector(i, n.match_id()));
}
return Some(out);
}
@@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let by_index = b_p(p, "byIndex").unwrap_or(false);
let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index)
.ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
@@ -206,7 +235,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)",
has_layers,
pick_whip
),
+1
View File
@@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
clip.push(crate::KeyClip { layer: lid, path, keys });
}
s.state.key_clipboard = clip;
s.state.keyframe_text = None;
s.state.clip_is_keys = true;
// The same data as text (tab-separated, one row per frame) for other applications.
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value};
/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]).
pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
+10 -10
View File
@@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePat
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
f("points", 5.0),
v2("position", [0.0; 2]),
f("rotation", 0.0),
f("innerRadius", 50.0),
f("outerRadius", 100.0),
f("innerRoundness", 0.0),
f("outerRoundness", 0.0),
),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
inner: f("innerRadius", 50.0),
outer: f("outerRadius", 100.0),
inner_round: f("innerRoundness", 0.0),
outer_round: f("outerRoundness", 0.0),
}),
_ => return None,
};
// A closed parametric shape is one subpath; anything else would not be this shape.
+1 -1
View File
@@ -229,7 +229,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Proxy...",
["File", "Interpret Footage"],
None,
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
has_proxy,
interpret_proxy
),
+53 -35
View File
@@ -203,25 +203,10 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
}
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`.
fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
@@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
Ok(())
}
/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`).
fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> {
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
@@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
om.resize.enabled = true;
}
}
Ok(())
}
/// `resize` (bool or object).
fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
@@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
Ok(())
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
apply_channels_and_alpha(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
apply_crop(om, roi, p, cmd)?;
apply_resize(om, p, cmd)?;
if let Some(a) = p.get("audio") {
om.audio = match a {
Value::Bool(true) => AudioOutput::On,
@@ -999,7 +1017,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
["Cmd+M", "Cmd+Shift+/"],
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -1019,7 +1037,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
+1 -1
View File
@@ -1,5 +1,5 @@
use super::params::bad;
use super::*;
use super::{CommandSpec, Result, Value};
// ---------- parameter validation (agents) ----------
+363 -17
View File
@@ -5,7 +5,8 @@
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
@@ -22,6 +23,11 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
/// The settings-store folders of installed scripts.
pub const SCRIPTS_DIR: &str = "Scripts";
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
const PANELS_FOLDER: &str = "ScriptUI Panels";
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
/// After Effects): never listed as File ▸ Scripts submenus.
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
@@ -111,7 +117,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ScriptEntry {
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
pub name: String,
/// In the ScriptUI Panels folder: opens as a dockable panel.
pub panel: bool,
@@ -119,16 +125,23 @@ pub struct ScriptEntry {
pub source: &'static str,
}
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
fn is_script(name: &str) -> bool {
let l = name.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js")
}
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
/// Windows none of the names [`windows_unsafe_name`] lists.
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI
/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of
/// the settings store, which takes them as relative paths), and on Windows none of the names
/// [`windows_unsafe_name`] lists.
fn is_script_name(name: &str) -> bool {
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
match name.split_once('/') {
Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
None => part(name),
}
}
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
@@ -155,8 +168,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
let mut v: Vec<ScriptEntry> = vec![];
if let Some(cfg) = &s.config {
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
}
}
}
}
@@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(scripts(s)))
}
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
/// folders.
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
let c = "file.uninstallScript";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
if !is_script(name) || !is_script_name(name) {
return Err(bad(c, format!("`{name}` is not a script file name")));
}
// A subfolder can be a link to files outside the settings folder (an extension's own
// folder), so only what Install puts at the top is removed, never a file through a folder.
if name.contains('/') {
return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
}
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
let mut removed = false;
for dir in [SCRIPTS_DIR, PANELS_DIR] {
@@ -235,13 +260,17 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
/// this way gets `this` = a floating palette-like panel window. A script listed under File ▸
/// Scripts runs before a panel of the same name.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
let found = script_code(s, name, Some(false)).or_else(|| script_code(s, name, None));
let (panel, code) = found.ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
if panel {
return open_panel(s, &json!({"name": name}));
}
super::file_more::run_js_named(s, &code, name)
let result = super::file_more::run_js_named(s, &code, name)?;
super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
Ok(result)
}
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
@@ -312,13 +341,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
let id = w.id;
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
return Ok(json!({"window": id, "open": true}));
}
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
if let Some(id) = id {
@@ -334,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"window": id, "result": out}))
}
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -354,14 +386,22 @@ pub fn specs() -> Vec<CommandSpec> {
always,
|s, p| install(s, p, true)
),
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
cmd!(
"file.uninstallScript",
"Uninstall Script",
[],
None,
"{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
always,
uninstall
),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list),
cmd!(
"window.scriptPanel",
"ScriptUI Panel",
[],
None,
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
always,
open_panel
),
@@ -500,13 +540,15 @@ mod tests {
}
}
/// Bundled and ordinary script file names are accepted; paths are not.
#[test]
fn script_names_are_bare_file_names() {
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
assert!(is_script(name) && is_script_name(name), "{name}");
}
for name in PATHS {
// `a/b.jsx` is a script in a folder (see below).
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
assert!(!is_script_name(name), "{name:?}");
}
for name in WINDOWS_ONLY {
@@ -524,6 +566,141 @@ mod tests {
}
}
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
#[test]
fn script_names_may_have_one_folder() {
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
assert!(is_script_name(name), "{name:?}");
}
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
assert!(!is_script_name(name), "{name:?}");
}
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
}
}
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
fn put(root: &std::path::Path, rel: &str, code: &str) {
let p = root.join("settings").join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, code).unwrap();
}
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
#[test]
fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() {
let (mut s, root) = session_in_tmp("folders");
put(&root, "Scripts/Tools/Hello.jsx", "hello");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
// Two folders deep: neither listed nor run.
put(&root, "Scripts/a/b/c.jsx", "too deep");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
let list = s.execute("file.scripts.list", json!({})).unwrap();
let installed: Vec<(&str, bool)> =
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
// A panel in a folder is titled after its file.
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
let ran = r["result"]["ran"].as_str().unwrap_or_default();
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
// A script that is not a panel doesn't open as one.
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
}
for name in [
"../x.jsx",
"a/b/c.jsx",
"a\\b.jsx",
"Motion Kit/sub/Deep.jsx",
"ScriptUI Panels/Top.jsx",
"Startup/Run.jsx",
"shutdown/Run.jsx",
"Tools/../../../outside.jsx",
] {
assert_eq!(script_code(&s, name, None), None, "{name:?}");
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
let r = s.execute(c, json!({"name": name}));
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
}
}
assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
assert!(root.join("outside.jsx").is_file());
// Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
}
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
let _ = std::fs::remove_dir_all(&root);
}
/// A subfolder can be a link to an extension's own folder, outside the settings folder: its
/// scripts are listed and run from there, but uninstall never deletes through it.
#[cfg(unix)]
#[test]
fn uninstall_never_deletes_through_a_linked_subfolder() {
let (mut s, root) = session_in_tmp("linked");
std::fs::create_dir_all(root.join("extension")).unwrap();
std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
assert!(bad_params(&r), "{r:?}");
assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
let _ = std::fs::remove_dir_all(&root);
}
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
/// sorts; the entries outside folders stay as they were.
#[test]
fn a_subfolder_is_a_submenu() {
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
let (s, root) = session_in_tmp("submenus");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
put(&root, "Scripts/Tools/Hello.jsx", "");
let cx = Default::default();
let item =
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
let menus = [
(
"scriptPanels",
json!({"type": "submenu", "label": "Motion Kit", "children": [
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
]}),
),
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
];
for (menu, folder) in menus {
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
let (entries, _) = dynamic(&s, menu, &cx);
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
assert_eq!(folders, [folder], "{menu}");
}
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
let labels: Vec<&str> = panels
.iter()
.filter_map(|n| match n {
MenuNode::Item(e) => Some(e.label.as_str()),
MenuNode::Submenu { label, .. } => Some(label),
_ => None,
})
.collect();
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn uninstall_takes_a_script_name_not_a_path() {
let (mut s, root) = session_in_tmp("uninstall");
@@ -553,6 +730,175 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
/// settings; Cmd+Alt+Shift+D runs the first one again.
#[test]
fn recent_script_files_run_again() {
use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
let (mut s, root) = session_in_tmp("recent");
assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
let tool = root.join("Tool.jsx");
std::fs::write(&tool, "tool").unwrap();
let tool = tool.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": tool})).unwrap();
s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
// A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
// Running one again runs that file and moves it to the top.
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
// A file named like a bundled script runs again as that file.
let local = root.join("Rename Layers.jsx");
std::fs::write(&local, "local").unwrap();
s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
// Ten at most, saved with the settings.
let mut last = String::new();
for i in 0..MAX_RECENT_SCRIPTS + 2 {
let f = root.join(format!("Step {i}.jsonl"));
std::fs::write(&f, "").unwrap();
last = f.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": last})).unwrap();
}
assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recent_script_history_uses_the_selected_script() {
let (mut s, root) = session_in_tmp("recent-panel-collision");
let name = "Rename Layers.jsx";
std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
assert!(script_code(&s, name, None).unwrap().0);
let sample = BUNDLED.iter().find(|b| b.0 == name).unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"name": name})).unwrap()["ran"], sample);
assert!(super::open_panel_names(&s).is_empty(), "the listed script runs instead of the panel");
assert_eq!(s.prefs.recent_scripts, [crate::prefs::RecentScript::Name(name.to_string())]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
}
let (mut s, root) = session_in_tmp("recent-failed");
s.script = Some(fail_or_echo);
let good = root.join("good.jsonl");
std::fs::write(&good, "[]").unwrap();
s.execute("file.runScript", json!({"path": good})).unwrap();
let remembered = s.prefs.recent_scripts.clone();
for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
{
let path = root.join(name);
std::fs::write(&path, code).unwrap();
assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
}
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
assert_eq!(s.prefs.recent_scripts, remembered);
let _ = std::fs::remove_dir_all(root);
}
/// Reads relative paths from a working directory that a test can change.
struct InFolder(std::sync::Mutex<PathBuf>);
impl InFolder {
fn at(&self, path: &str) -> PathBuf {
self.0.lock().unwrap().join(path)
}
}
impl crate::Services for InFolder {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.at(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.at(path), data)
}
fn full_path(&self, path: &str) -> Option<String> {
Some(self.at(path).to_string_lossy().into_owned())
}
}
/// Reads from a folder but can't make paths absolute.
struct NoFullPath(PathBuf);
impl crate::Services for NoFullPath {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.0.join(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.0.join(path), data)
}
fn full_path(&self, _path: &str) -> Option<String> {
None
}
}
/// A script whose path can't be made absolute still runs, and Recent Script Files does not
/// keep its relative path.
#[test]
fn unresolved_script_paths_are_not_remembered() {
let (mut s, root) = session_in_tmp("recent-unresolved");
std::fs::write(root.join("Tool.jsx"), "a").unwrap();
std::fs::write(root.join("steps.jsonl"), "[]").unwrap();
s.services = Arc::new(NoFullPath(root.clone()));
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
s.execute("file.runScript", json!({"path": "steps.jsonl"})).unwrap();
assert!(s.prefs.recent_scripts.is_empty(), "{:?}", s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
/// A script run from a relative path runs the same file again from Recent Script Files after
/// the working directory changes, and two scripts with the same file name show their paths.
#[test]
fn recent_script_files_keep_the_file_that_ran() {
let (mut s, root) = session_in_tmp("recent-relative");
let (a, b) = (root.join("a"), root.join("b"));
for (dir, code) in [(&a, "a"), (&b, "b")] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Tool.jsx"), code).unwrap();
}
let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
s.services = services.clone();
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
*services.0.lock().unwrap() = b.clone();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
let _ = std::fs::remove_dir_all(&root);
}
/// Native script paths are kept absolute.
#[test]
fn native_full_paths_are_absolute() {
use crate::Services;
assert!(crate::FsServices.full_path("Tool.jsx").is_some_and(|p| std::path::Path::new(&p).is_absolute()));
}
#[test]
fn only_scripts_are_read_or_removed() {
// A settings file in the Scripts folder that is not a script (.jsx / .js).
+10 -1
View File
@@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0)
effectcraft_path::polystar(effectcraft_path::Polystar {
star,
points,
pos: centre,
rotation: 0.0,
inner,
outer,
inner_round: 0.0,
outer_round: 0.0,
})
}
};
effectcraft_path::from_kurbo(&bez).into_iter().next()