mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
Merge main into #2452; resolve crates/engine/src/cmd/animation.rs crates/engine/src/tests/effects.rs
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EngineError, Result, Session, Value};
|
||||
|
||||
// ---------- enablement ----------
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use super::params::bad;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Value};
|
||||
|
||||
// ---------- parameter validation (agents) ----------
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user