mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
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();
|
||||
|
||||
@@ -847,10 +847,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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+310
-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);
|
||||
@@ -732,14 +1017,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
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -205,23 +205,8 @@ 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";
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::params::bad;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Value};
|
||||
|
||||
// ---------- parameter validation (agents) ----------
|
||||
|
||||
|
||||
@@ -257,13 +257,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.
|
||||
@@ -714,6 +718,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