mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
M13.15: qa scenarios 1-4 (kinetic type, shape logo, compositing, tracking) and fixes
engine.batch + MCP batch tool; layer.newText/newShape name; prop.get evaluated expression value; keys.select by path with errors; @uid paths; plain-JSON gradients; addShapeItem/effect.apply return paths; unknown layers error; conform rescales footage duration; render log name for [###]; render_frame transparent; clearer mask errors; truncated groups in flat get_layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
95766755f3
commit
fecf054e5f
@@ -0,0 +1,171 @@
|
||||
//! `engine.batch`: run several commands in one call, as one undo step, with later steps able to
|
||||
//! use what earlier ones returned (`"$1.layer"` is step 1's `layer` result). Agents build a
|
||||
//! layer, animate it and style it in one round trip; a failing step rolls everything back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Commands that can't run inside a batch (they replace the project or nest batches).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
|
||||
|
||||
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
|
||||
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
|
||||
let rest = s.strip_prefix('$')?;
|
||||
let mut parts = rest.split('.');
|
||||
let n: usize = parts.next()?.parse().ok()?;
|
||||
let path: Vec<&str> = parts.collect();
|
||||
if path.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) {
|
||||
return None;
|
||||
}
|
||||
let Some(mut v) = n.checked_sub(1).and_then(|i| results.get(i)) else {
|
||||
return Some(Err(bad("engine.batch", format!("`{s}` refers to step {n}, but only {} step(s) ran before", results.len()))));
|
||||
};
|
||||
for p in &path {
|
||||
let next = match (v, p.parse::<usize>()) {
|
||||
(Value::Array(a), Ok(i)) => a.get(i),
|
||||
(Value::Object(o), _) => o.get(*p),
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(x) => v = x,
|
||||
None => return Some(Err(bad("engine.batch", format!("`{s}`: step {n} returned {v}, which has no `{p}`")))),
|
||||
}
|
||||
}
|
||||
Some(Ok(v.clone()))
|
||||
}
|
||||
|
||||
/// Replace every `$N…` string in `v` (recursively) with the referenced result.
|
||||
fn substitute(v: &Value, results: &[Value]) -> Result<Value> {
|
||||
Ok(match v {
|
||||
Value::String(s) => match reference(s, results) {
|
||||
Some(r) => r?,
|
||||
None => v.clone(),
|
||||
},
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| substitute(x, results)).collect::<Result<_>>()?),
|
||||
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), substitute(x, results)?))).collect::<Result<_>>()?),
|
||||
_ => v.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone();
|
||||
let label = str_p(p, "label").unwrap_or("Batch").to_string();
|
||||
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
|
||||
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
|
||||
let id = step
|
||||
.get("command")
|
||||
.or_else(|| step.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
|
||||
if NOT_IN_BATCH.contains(&id) {
|
||||
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
|
||||
}
|
||||
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
|
||||
s.execute_checked(id, params)
|
||||
};
|
||||
match run(s, &results) {
|
||||
Ok(r) => results.push(r),
|
||||
Err(e) => {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
|
||||
let note = if atomic {
|
||||
s.project = project;
|
||||
s.history = history;
|
||||
s.state = state;
|
||||
s.sanitize_state();
|
||||
s.bump();
|
||||
"; the batch was rolled back (nothing changed)".to_string()
|
||||
} else {
|
||||
fold(s, &project, &label);
|
||||
format!("; steps 1–{i} were applied")
|
||||
};
|
||||
return Err(EngineError::Other(format!("step {} ({id}): {e}{note}", i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
fold(s, &project, &label);
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// Fold the undo steps recorded since the project was `snap` into one step called `label`.
|
||||
fn fold(s: &mut Session, snap: &Arc<effectcraft_project::Project>, label: &str) {
|
||||
let h = &mut s.history;
|
||||
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { return };
|
||||
let first = h.undo[start].1.clone();
|
||||
h.undo.truncate(start);
|
||||
h.undo.push((label.to_string(), first));
|
||||
h.merge_key = None;
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "engine.batch",
|
||||
label: "Run Commands (Batch)",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
|
||||
enabled: always,
|
||||
run: batch,
|
||||
// The steps journal themselves.
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn batch_refs_one_undo_step_and_rollback() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "B", "width": 100, "height": 100})).unwrap();
|
||||
let undo_before = s.history.undo.len();
|
||||
let r = s
|
||||
.execute_checked(
|
||||
"engine.batch",
|
||||
json!({"label": "Build", "steps": [
|
||||
{"command": "layer.newSolid", "params": {"name": "Box", "color": "#ff0000"}},
|
||||
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/opacity", "value": 40}},
|
||||
{"command": "layer.rename", "params": {"layer": "$1.layer", "name": "Red Box"}},
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["steps"], 3);
|
||||
let lid = r["results"][0]["layer"].as_u64().unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
let l = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
|
||||
assert_eq!(l.name, "Red Box");
|
||||
assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 40.0);
|
||||
assert_eq!(s.history.undo.len(), undo_before + 1, "one undo step");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Build");
|
||||
assert!(s.undo());
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
assert!(s.redo());
|
||||
|
||||
// A failing step rolls back the earlier ones and names the step.
|
||||
let n = s.active_comp().unwrap().layers.len();
|
||||
let e = s
|
||||
.execute_checked(
|
||||
"engine.batch",
|
||||
json!({"steps": [
|
||||
{"command": "layer.newNull", "params": {}},
|
||||
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/nope", "value": 1}},
|
||||
]}),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.starts_with("step 2 (prop.set)") && e.contains("rolled back"), "{e}");
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), n);
|
||||
// Bad references are reported.
|
||||
let e = s.execute_checked("engine.batch", json!({"steps": [{"command": "layer.rename", "params": {"layer": "$3.layer", "name": "x"}}]})).unwrap_err();
|
||||
assert!(e.to_string().contains("refers to step 3"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,17 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.last_effect = Some(spec.id.to_string());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"effects": uids}))
|
||||
// Each instance's property path prefix (`effects/#2`), ready for prop.set paths.
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let paths: Vec<Value> = sizes
|
||||
.iter()
|
||||
.zip(&uids)
|
||||
.map(|((lid, _), uid)| {
|
||||
let n = comp.layer(*lid).and_then(|l| l.props.group("effects")).and_then(|fx| fx.children.iter().position(|c| c.uid() == *uid));
|
||||
json!(n.map(|n| format!("effects/#{}", n + 1)))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"effects": uids, "paths": paths, "effect": spec.id}))
|
||||
}
|
||||
|
||||
fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, effectcraft_project::LayerId, Uid)> {
|
||||
|
||||
@@ -528,6 +528,18 @@ impl Interpretation {
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
let before = f.frame_rate;
|
||||
self.apply_settings(f, guessed);
|
||||
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
|
||||
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
|
||||
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
|
||||
if matches!(f.kind, FootageKind::Video | FootageKind::Sequence) && old > 0.0 && new > 0.0 && (old - new).abs() > 1e-9 {
|
||||
let frames = (f.duration.seconds() * old).round() as i64;
|
||||
f.duration = f.frame_rate.tick_of(frames);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_settings(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
if self.native {
|
||||
if let Some(n) = f.native_rate.take() {
|
||||
f.frame_rate = n;
|
||||
@@ -617,8 +629,21 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
it.apply(f, *g);
|
||||
let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) else { continue };
|
||||
let old = f.duration;
|
||||
it.apply(f, *g);
|
||||
let new = f.duration;
|
||||
if new == old {
|
||||
continue;
|
||||
}
|
||||
// Layers that ran to the footage's end still do (After Effects re-times them too).
|
||||
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in ids {
|
||||
for l in proj.comp_mut(cid).into_iter().flat_map(|c| c.layers.iter_mut()) {
|
||||
if l.source.item() == Some(*i) && (l.stretch - 100.0).abs() < 1e-9 && l.out_point == l.start_time + old {
|
||||
l.out_point = l.start_time + new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -115,7 +115,9 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("box");
|
||||
o.remove("name");
|
||||
}
|
||||
let layer_name = str_p(p, "name").map(str::to_string);
|
||||
apply_text_params("layer.newText", &q, &mut doc, None)?;
|
||||
let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
if let Some([x, y, w, h]) = bx {
|
||||
@@ -129,7 +131,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = doc.text.lines().next().unwrap_or("").chars().take(40).collect();
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
@@ -848,7 +850,7 @@ fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let inverted = b_p(p, "inverted");
|
||||
s.edit("Mask Settings", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "no masks"))?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "the layer has no masks (add one with layer.addMask)"))?;
|
||||
let g = match &target {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1);
|
||||
@@ -878,7 +880,18 @@ fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?;
|
||||
let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string();
|
||||
let group_uid = p.get("group").and_then(Value::as_u64);
|
||||
// `group`: a uid or a property path (`contents/group`).
|
||||
let group_uid = match p.get("group") {
|
||||
Some(Value::String(path)) => Some(
|
||||
s.project
|
||||
.comp(cid)
|
||||
.and_then(|c| c.layer(lid))
|
||||
.and_then(|l| l.props.group(path))
|
||||
.map(|g| g.uid)
|
||||
.ok_or_else(|| bad("layer.addShapeItem", format!("no group `{path}`")))?,
|
||||
),
|
||||
v => v.and_then(Value::as_u64),
|
||||
};
|
||||
let uid = s.edit("Add Shape Item", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
@@ -909,7 +922,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
target.children.push(g.into());
|
||||
Ok(uid)
|
||||
})?;
|
||||
Ok(json!({"uid": uid}))
|
||||
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
|
||||
let path = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.match_path_of(uid));
|
||||
Ok(json!({"uid": uid, "path": path}))
|
||||
}
|
||||
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1066,7 +1081,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -1077,7 +1092,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Layer",
|
||||
["Layer", "New"],
|
||||
None,
|
||||
"{kind?: rect|rounded|ellipse|star|polygon|none, size?, fill?, stroke?, strokeWidth?, position?}",
|
||||
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}",
|
||||
has_comp,
|
||||
new_shape
|
||||
),
|
||||
@@ -1154,7 +1169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid}",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
||||
@@ -304,7 +304,9 @@ fn edit_masks(
|
||||
) -> Result<Value> {
|
||||
let (cid, targets) = target_masks(s, p)?;
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("no masks".into()));
|
||||
return Err(EngineError::Other(format!(
|
||||
"{label}: no masks to change (the layer has none, or `mask` matched none): add one with layer.addMask {{layer, shape?: rect|ellipse, rect?: [x,y,w,h]}}"
|
||||
)));
|
||||
}
|
||||
let t = s.time();
|
||||
let sizes: Vec<(LayerId, (f64, f64))> = targets
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod anim_tools;
|
||||
mod animation;
|
||||
pub(crate) mod app_more;
|
||||
mod autotrace;
|
||||
mod batch;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
pub(crate) mod comp_more;
|
||||
@@ -155,6 +156,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(render_queue::specs());
|
||||
v.extend(help::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(batch::specs());
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(animation::specs());
|
||||
@@ -413,11 +415,14 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
|
||||
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
|
||||
let resolve =
|
||||
|v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)")));
|
||||
if let Some(Value::Array(a)) = p.get("layers") {
|
||||
return Ok((cid, a.iter().filter_map(|v| resolve_layer(comp, v)).collect()));
|
||||
return Ok((cid, a.iter().map(resolve).collect::<Result<_>>()?));
|
||||
}
|
||||
if let Some(v) = p.get("layer") {
|
||||
return Ok((cid, resolve_layer(comp, v).into_iter().collect()));
|
||||
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
|
||||
return Ok((cid, vec![resolve(v)?]));
|
||||
}
|
||||
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
|
||||
}
|
||||
|
||||
@@ -111,11 +111,28 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
||||
let keys: Vec<Value> =
|
||||
pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect();
|
||||
Ok(json!({
|
||||
let raw = pr.value_at(l.layer_time(t));
|
||||
let mut out = json!({
|
||||
"layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(),
|
||||
"time": t.seconds(), "value": pr.value_at(l.layer_time(t)).to_json(), "animated": !pr.keys.is_empty(),
|
||||
"time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(),
|
||||
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
|
||||
}))
|
||||
});
|
||||
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
|
||||
// expression makes of it (what renders), or why it fails.
|
||||
if pr.has_expression()
|
||||
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
|
||||
{
|
||||
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
ctx.expr = Some(h);
|
||||
match h.eval(&ctx, l, pr, &raw) {
|
||||
Ok(v) => out["evaluated"] = v.to_json(),
|
||||
Err(e) => {
|
||||
out["evaluated"] = raw.to_json();
|
||||
out["expressionError"] = json!(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -256,16 +273,27 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut sel = vec![];
|
||||
if let Some(Value::Array(keys)) = p.get("keys") {
|
||||
for k in keys {
|
||||
let lid = k.get("layer").and_then(|v| super::resolve_layer(comp, v));
|
||||
let uid = k.get("prop").and_then(Value::as_u64);
|
||||
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64);
|
||||
if let (Some(l), Some(u), Some(t)) = (lid, uid, t) {
|
||||
// Snap to the stored key time.
|
||||
let kt = comp.layer(l).and_then(|ly| ly.props.find(u)).and_then(|pr| pr.keys.iter().map(|k| k.time).min_by_key(|kt| (kt.0 - t.0).abs()));
|
||||
if let Some(kt) = kt {
|
||||
sel.push(KeyRef { layer: l, prop: u, time: kt });
|
||||
}
|
||||
// `{layer, prop: uid | path, time}` (or `path`); anything that matches nothing is an
|
||||
// error rather than a silently empty selection.
|
||||
let lv = k.get("layer").ok_or_else(|| bad("keys.select", format!("{k}: missing `layer`")))?;
|
||||
let l = super::resolve_layer(comp, lv).ok_or_else(|| bad("keys.select", format!("no layer {lv}")))?;
|
||||
let ly = comp.layer(l).ok_or(EngineError::NoComp)?;
|
||||
let pr = match (k.get("prop"), k.get("path").and_then(Value::as_str)) {
|
||||
(Some(Value::Number(n)), _) => n.as_u64().and_then(|u| ly.props.find(u)),
|
||||
(Some(Value::String(path)), _) => ly.props.prop(path),
|
||||
(None, Some(path)) => ly.props.prop(path),
|
||||
_ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))),
|
||||
}
|
||||
.ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?;
|
||||
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
|
||||
// Snap to the stored key time.
|
||||
let kt = pr
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| k.time)
|
||||
.min_by_key(|kt| (kt.0 - t.0).abs())
|
||||
.ok_or_else(|| bad("keys.select", format!("`{}` has no keyframes", pr.name)))?;
|
||||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
@@ -591,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
convert_expr_to_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop, time}], add?}", has_comp, select_keys),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
|
||||
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
|
||||
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
|
||||
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
|
||||
|
||||
@@ -122,6 +122,10 @@ fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, d
|
||||
.collect()
|
||||
};
|
||||
let mut o = json!({"path": path, "uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children});
|
||||
if depth >= max_depth && !g.children.is_empty() {
|
||||
// Not expanded at this `depth`: ask again with a larger one (or for this group's layer).
|
||||
o["truncated"] = json!(true);
|
||||
}
|
||||
match &g.kind {
|
||||
GroupKind::Mask { mode, inverted, .. } => {
|
||||
o["mask"] = json!({"mode": mode.label(), "inverted": inverted});
|
||||
|
||||
@@ -712,6 +712,19 @@ impl Session {
|
||||
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
|
||||
}
|
||||
|
||||
/// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a
|
||||
/// render with RGB + Alpha channels writes) instead of compositing over the background.
|
||||
pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec<u8>)> {
|
||||
if !transparent {
|
||||
return self.render_rgba8(comp, t, max_side);
|
||||
}
|
||||
let c = self.project.comp(comp).ok_or(EngineError::NoComp)?;
|
||||
let long = c.width.max(c.height).max(1) as f64;
|
||||
let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) };
|
||||
let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
|
||||
Ok((img.width, img.height, img.to_rgba8()))
|
||||
}
|
||||
|
||||
/// Replace the whole project (open/new), resetting history and state.
|
||||
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
|
||||
if self.stop_render()
|
||||
|
||||
Reference in New Issue
Block a user