mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
Merge main into IO-2/SHP-5/OUT-2/PRV-2 work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,7 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
apply_settings(c, p);
|
||||
super::model3d::sync_geometry_options(proj, cid);
|
||||
Ok(())
|
||||
})?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
|
||||
@@ -164,7 +164,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let label = match f.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video => Label::Aqua,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
|
||||
@@ -359,9 +359,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- scripts
|
||||
|
||||
/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also
|
||||
/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After
|
||||
/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON
|
||||
/// 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> {
|
||||
if let Some(path) = str_p(p, "path")
|
||||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
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)?;
|
||||
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
|
||||
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
|
||||
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
@@ -389,7 +410,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" {
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
@@ -398,6 +419,28 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// Run JavaScript through the session's scripting engine.
|
||||
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
|
||||
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
|
||||
Ok(runner(s, &crate::ScriptRequest { code, name, console }))
|
||||
}
|
||||
|
||||
/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps,
|
||||
/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in
|
||||
/// `error` (with line and column), not as a command failure.
|
||||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -639,10 +682,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsonl/.json command script) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
"script.run",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}",
|
||||
always,
|
||||
script_run
|
||||
),
|
||||
cmd!(
|
||||
"file.interpretFootage",
|
||||
"Main...",
|
||||
|
||||
@@ -24,7 +24,11 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
|
||||
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
|
||||
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
|
||||
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let id = layer.id;
|
||||
@@ -235,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
|
||||
(LayerSource::Footage { item }, (f.width, f.height), d)
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Liquify brush strokes (`liquify.stroke`, `liquify.clear`).
|
||||
//!
|
||||
//! A stroke is appended to the layer's Liquify effect (added when the layer has none) as one
|
||||
//! line of its hidden Distortion Mesh data (see `effectcraft_effects::distort4`). Brush size,
|
||||
//! pressure, turbulent jitter and clone offset default to the effect's own tool options. When
|
||||
//! the Distortion Mesh is animated, the stroke goes into a keyframe at the current time (copying
|
||||
//! the mesh in effect there), as After Effects records mesh keyframes.
|
||||
|
||||
use effectcraft_effects::distort4::{LIQUIFY_TOOLS, LiquifyStroke, parse_strokes};
|
||||
use effectcraft_keyframe::{Keyframe, Value as KV};
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const ID: &str = "ec.distort.liquify";
|
||||
|
||||
fn is_liquify(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == ID)
|
||||
}
|
||||
|
||||
fn liquify_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2], want: Option<Uid>) -> Result<Uid> {
|
||||
if let Some(u) = want {
|
||||
return fx.groups().find(|g| g.uid == u && is_liquify(g)).map(|g| g.uid).ok_or_else(|| bad("liquify.stroke", "no such Liquify effect"));
|
||||
}
|
||||
if let Some(g) = fx.groups().filter(|g| is_liquify(g)).last() {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
let spec = effectcraft_effects::find(ID).ok_or_else(|| EngineError::Other("Liquify effect missing".into()))?;
|
||||
let g = effectcraft_effects::instantiate(spec, ids, "Liquify", layer_size);
|
||||
let uid = g.uid;
|
||||
fx.children.push(g.into());
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
fn num_of(g: &PropGroup, id: &str, default: f64) -> f64 {
|
||||
g.get(id).map(|p| p.value.as_f64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "liquify.stroke";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let tool_name = str_p(p, "tool").unwrap_or("warp");
|
||||
let tool = LIQUIFY_TOOLS
|
||||
.iter()
|
||||
.position(|t| t.eq_ignore_ascii_case(tool_name))
|
||||
.ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?;
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?;
|
||||
let mut points = vec![];
|
||||
for q in raw {
|
||||
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
|
||||
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
|
||||
return Err(bad(cmd, "each point is [x, y]"));
|
||||
};
|
||||
points.push([x, y]);
|
||||
}
|
||||
if points.is_empty() {
|
||||
return Err(bad(cmd, "`points` is empty"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp)));
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
|
||||
if layer.effects().is_none() || !layer.source.is_av() {
|
||||
return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers"));
|
||||
}
|
||||
let lt = layer.layer_time(t);
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
|
||||
let want = p.get("effect").and_then(Value::as_u64);
|
||||
let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
|
||||
let mut ids = Ids(&mut next);
|
||||
let fx_uid = liquify_effect(fx, &mut ids, size, want)?;
|
||||
proj.next_id = next;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let g = l.props.sub_mut("effects").and_then(|fx| fx.find_group_mut(fx_uid)).ok_or_else(|| bad(cmd, "Liquify effect gone"))?;
|
||||
let co = g.get("cloneOffset").map(|p| p.value.as_vec2()).unwrap_or([0.0; 2]);
|
||||
let st = LiquifyStroke {
|
||||
tool,
|
||||
size: f_p(p, "size").unwrap_or_else(|| num_of(g, "brushSize", 64.0)).max(1.0),
|
||||
pressure: f_p(p, "pressure").unwrap_or_else(|| num_of(g, "brushPressure", 50.0)).clamp(1.0, 100.0),
|
||||
jitter: f_p(p, "jitter").unwrap_or_else(|| num_of(g, "turbulentJitter", 70.0)).clamp(1.0, 100.0),
|
||||
clone_offset: match p.get("cloneOffset").and_then(Value::as_array) {
|
||||
Some(a) => [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)],
|
||||
None => co,
|
||||
},
|
||||
points: points.clone(),
|
||||
};
|
||||
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "Liquify effect has no Distortion Mesh"))?;
|
||||
let append = |cur: &str| if cur.trim().is_empty() { st.to_line() } else { format!("{}\n{}", cur.trim_end(), st.to_line()) };
|
||||
let text = if prop.keys.is_empty() {
|
||||
let cur = match &prop.value {
|
||||
KV::Str(x) => x.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let v = append(&cur);
|
||||
prop.value = KV::Str(v.clone());
|
||||
v
|
||||
} else {
|
||||
// Hold keyframes: the mesh in effect at `lt` is the last key at or before it.
|
||||
let cur = prop.keys.iter().rfind(|k| k.time <= lt).or(prop.keys.first()).map(|k| k.value.clone());
|
||||
let cur = match cur {
|
||||
Some(KV::Str(x)) => x,
|
||||
_ => String::new(),
|
||||
};
|
||||
let v = append(&cur);
|
||||
match prop.keys.iter_mut().find(|k| k.time == lt) {
|
||||
Some(k) => k.value = KV::Str(v.clone()),
|
||||
None => {
|
||||
prop.keys.push(Keyframe::new(lt, KV::Str(v.clone())));
|
||||
prop.keys.sort_by_key(|k| k.time);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
Ok((fx_uid, parse_strokes(&text).len()))
|
||||
})?;
|
||||
Ok(json!({"effect": fx_uid, "strokes": count}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "liquify.clear";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let want = p.get("effect").and_then(Value::as_u64);
|
||||
s.edit("Clear Liquify Mesh", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let uid =
|
||||
fx.groups().filter(|g| is_liquify(g) && want.is_none_or(|u| u == g.uid)).last().map(|g| g.uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
|
||||
let g = fx.find_group_mut(uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
|
||||
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "no Distortion Mesh"))?;
|
||||
prop.keys.clear();
|
||||
prop.value = KV::Str(String::new());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"liquify.stroke",
|
||||
"Liquify Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
|
||||
has_comp,
|
||||
stroke
|
||||
),
|
||||
cmd!("liquify.clear", "Clear Liquify Mesh", [], None, "{layer?, effect?: uid}", has_comp, clear),
|
||||
]
|
||||
}
|
||||
@@ -18,14 +18,17 @@ mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
mod markers;
|
||||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
pub mod paint;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
@@ -102,10 +105,12 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer::specs());
|
||||
v.extend(styles::specs());
|
||||
v.extend(three_d::specs());
|
||||
v.extend(model3d::specs());
|
||||
v.extend(text_anim::specs());
|
||||
v.extend(text_edit::specs());
|
||||
v.extend(layer_time::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(prop_groups::specs());
|
||||
v.extend(project_items::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(anim_tools::specs());
|
||||
@@ -133,6 +138,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(warp_cmds::specs());
|
||||
v.extend(paint::specs());
|
||||
v.extend(puppet::specs());
|
||||
v.extend(liquify::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
|
||||
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
|
||||
//! shapes, and Layer ▸ Material commands.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Node, PrimitiveKind, PropGroup, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
// ---------------------------------------------------------------- geometry options
|
||||
|
||||
/// Add Geometry Options (Bevel Style, Bevel Depth, Hole Bevel Depth, Extrusion Depth) to a text
|
||||
/// or shape layer that has none.
|
||||
pub(crate) fn add_geometry_options(next_id: &mut u64, l: &mut Layer) {
|
||||
if !matches!(l.source, LayerSource::Text | LayerSource::Shape) || l.props.sub("geometryOptions").is_some() {
|
||||
return;
|
||||
}
|
||||
let g = build::extrusion_geometry_options(&mut Ids(next_id));
|
||||
// After Transform, like After Effects.
|
||||
let at = l.props.children.iter().position(|c| c.match_id() == "transform").map_or(l.props.children.len(), |i| i + 1);
|
||||
l.props.children.insert(at, g.into());
|
||||
}
|
||||
|
||||
/// Advanced 3D comps: give every text and shape layer Geometry Options.
|
||||
pub(crate) fn sync_geometry_options(proj: &mut effectcraft_project::Project, cid: ItemId) {
|
||||
if proj.comp(cid).is_none_or(|c| c.renderer != Renderer::Advanced3D) {
|
||||
return;
|
||||
}
|
||||
let mut next = proj.next_id;
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in &mut c.layers {
|
||||
add_geometry_options(&mut next, l);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
}
|
||||
|
||||
fn renderer_p(p: &Value, cmd: &str) -> Result<Renderer> {
|
||||
let v = str_p(p, "renderer").or_else(|| str_p(p, "value")).ok_or_else(|| bad(cmd, "missing `renderer` (advanced3d|classic3d)"))?;
|
||||
match v.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"advanced3d" | "advanced" => Ok(Renderer::Advanced3D),
|
||||
"classic3d" | "classic" => Ok(Renderer::Classic3D),
|
||||
_ => Err(bad(cmd, format!("renderer: advanced3d|classic3d, not `{v}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
if p.get("renderer").is_none() && p.get("value").is_none() {
|
||||
let r = s.project.comp(cid).ok_or(EngineError::NoComp)?.renderer;
|
||||
return Ok(json!({"renderer": renderer_id(r)}));
|
||||
}
|
||||
let r = renderer_p(p, "comp.renderer")?;
|
||||
s.edit("Change Renderer", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.renderer = r;
|
||||
sync_geometry_options(proj, cid);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"renderer": renderer_id(r)}))
|
||||
}
|
||||
|
||||
fn renderer_id(r: Renderer) -> &'static str {
|
||||
match r {
|
||||
Renderer::Advanced3D => "advanced3d",
|
||||
Renderer::Classic3D => "classic3d",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model layers
|
||||
|
||||
/// Set a property's value at comp time `t` (adds a key when it is animated).
|
||||
fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) -> bool {
|
||||
let lt = l.layer_time(t);
|
||||
match l.props.prop_mut(path) {
|
||||
Some(pr) => {
|
||||
pr.set_value_at(lt, v);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// New 3D model layer from a model footage item (`item`) or a file (`path`, imported first).
|
||||
pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let item = match (p.get("item"), str_p(p, "path")) {
|
||||
(Some(Value::Number(n)), _) => ItemId(n.as_u64().unwrap_or(0)),
|
||||
(Some(Value::String(name)), _) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.newModel", format!("no item `{name}`")))?,
|
||||
(_, Some(path)) => {
|
||||
let r = s.execute("file.import", json!({"paths": [path]}))?;
|
||||
let id = r.get("items").and_then(|a| a.get(0)).and_then(Value::as_u64);
|
||||
let err = r.get("errors").and_then(|e| e.get(0)).and_then(Value::as_str).unwrap_or("import failed").to_string();
|
||||
ItemId(id.ok_or_else(|| bad("layer.newModel", err))?)
|
||||
}
|
||||
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.newModel", "missing `item` or `path`"))?,
|
||||
};
|
||||
let it = s.project.item(item).ok_or_else(|| bad("layer.newModel", "no such item"))?.clone();
|
||||
let ItemKind::Footage(f) = &it.kind else { return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name))) };
|
||||
if f.kind != FootageKind::Model {
|
||||
return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name)));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
// Fit the model to half the comp height, and list its animation clips.
|
||||
let model = s.footage.model(item, f);
|
||||
let (scale, clips) = match &model {
|
||||
Some(m) => {
|
||||
let ext = m.bounds().map_or(1.0, |(lo, hi)| (0..3).map(|k| hi[k] - lo[k]).fold(0.0f64, f64::max));
|
||||
let scale = if ext > 1e-9 { comp.height as f64 * 0.5 / ext } else { 100.0 };
|
||||
let clips = m.animations.iter().enumerate().map(|(i, a)| if a.name.is_empty() { format!("Animation {}", i + 1) } else { a.name.clone() }).collect();
|
||||
(scale, clips)
|
||||
}
|
||||
None => (100.0, vec![]),
|
||||
};
|
||||
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let id = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
|
||||
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
|
||||
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
|
||||
*slot = g.into();
|
||||
}
|
||||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = comp.duration.max(start + fr.frame_duration());
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "item": item.0}))
|
||||
}
|
||||
|
||||
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
|
||||
let k = str_p(p, "kind").or_else(|| str_p(p, "type")).ok_or_else(|| bad(cmd, "missing `kind` (cube|sphere|plane|torus|cone|cylinder)"))?;
|
||||
PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`")))
|
||||
}
|
||||
|
||||
/// Parse Material Options parameters onto a layer at comp time `t`.
|
||||
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
let tri = |v: &Value| -> Option<u32> {
|
||||
match v {
|
||||
Value::Bool(b) => Some(*b as u32),
|
||||
Value::Number(x) => x.as_u64().map(|x| x.min(2) as u32),
|
||||
Value::String(s) => match s.to_ascii_lowercase().as_str() {
|
||||
"off" | "false" => Some(0),
|
||||
"on" | "true" => Some(1),
|
||||
"only" => Some(2),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for k in ["castsShadows", "acceptsShadows"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?;
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize;
|
||||
}
|
||||
}
|
||||
for k in ["acceptsLights", "appearsInReflections"] {
|
||||
if let Some(v) = b_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize;
|
||||
}
|
||||
}
|
||||
for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] {
|
||||
if let Some(v) = f_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize;
|
||||
}
|
||||
}
|
||||
for k in ["baseColor", "emissive"] {
|
||||
if let Some(c) = color_p(p, k) {
|
||||
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let kind = kind_p(p, "layer.newPrimitive")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
|
||||
let t = super::time_p(s, p, Some(&comp));
|
||||
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
|
||||
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
|
||||
if let Some(v) = f_p(p, k) {
|
||||
set_at(&mut l, &format!("geometryOptions/{k}"), t, KV::Scalar(v.max(0.0)));
|
||||
}
|
||||
}
|
||||
if let Some(pos) = p.get("position").and_then(Value::as_array) {
|
||||
let g = |i: usize| pos.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
|
||||
}
|
||||
apply_material(&mut l, p, t, "layer.newPrimitive")?;
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
// Primitives are drawn by the Advanced 3D renderer.
|
||||
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
|
||||
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- environment layer
|
||||
|
||||
fn environment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.environment", "select a footage or composition layer"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for id in &ids {
|
||||
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
|
||||
if !l.source.is_av() || l.source.is_model() {
|
||||
return Err(bad("layer.environment", format!("`{}` can't be an environment layer (use a footage, comp or solid layer)", l.name)));
|
||||
}
|
||||
}
|
||||
let on = b_p(p, "on").unwrap_or_else(|| !comp.layer(ids[0]).is_some_and(|l| l.environment));
|
||||
s.edit("Environment Layer", None, |proj, _| {
|
||||
for id in &ids {
|
||||
let l = layer_mut(proj, cid, *id)?;
|
||||
l.environment = on;
|
||||
if on {
|
||||
l.switches.three_d = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"environment": on, "layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- materials
|
||||
|
||||
fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("material.set", "no `layers` given and no layer selected"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let t = super::time_p(s, p, Some(&comp));
|
||||
let n = s.edit("Material Options", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?;
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(bad(
|
||||
"material.set",
|
||||
"no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)",
|
||||
));
|
||||
}
|
||||
Ok(json!({"set": n}))
|
||||
}
|
||||
|
||||
/// Fresh Material Options for a layer.
|
||||
fn default_material(next: &mut u64, l: &Layer) -> Option<PropGroup> {
|
||||
let ids = &mut Ids(next);
|
||||
match l.source {
|
||||
LayerSource::Primitive { .. } => Some(build::model_material_options(ids, true)),
|
||||
LayerSource::Model { .. } => Some(build::model_material_options(ids, false)),
|
||||
_ if l.source.is_av() => Some(build::material_options(ids)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_material(l: &mut Layer, g: PropGroup) {
|
||||
match l.props.children.iter_mut().find(|c| c.match_id() == "materialOptions") {
|
||||
Some(slot) => *slot = Node::Group(g),
|
||||
None => l.props.children.push(g.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn material_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("material.reset", "no `layers` given and no layer selected"));
|
||||
}
|
||||
s.edit("Reset Material", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for id in &ids {
|
||||
let l = layer_mut(proj, cid, *id)?;
|
||||
if let Some(g) = default_material(&mut next, l) {
|
||||
replace_material(l, g);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Duplicate and Assign Material: the first layer's Material Options are copied onto the other
|
||||
/// selected layers of the same kind.
|
||||
fn material_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.len() < 2 {
|
||||
return Err(bad("material.duplicateAssign", "select the source layer first, then the layers to assign its material to"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let src = comp
|
||||
.layer(ids[0])
|
||||
.and_then(|l| l.props.sub("materialOptions"))
|
||||
.cloned()
|
||||
.ok_or_else(|| bad("material.duplicateAssign", "the first layer has no material"))?;
|
||||
let n = s.edit("Duplicate and Assign Material", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids[1..] {
|
||||
let l = layer_mut(proj, cid, *id)?;
|
||||
let Some(dst) = l.props.sub("materialOptions") else { continue };
|
||||
// Property sets differ (models, primitives, cards): copy the values they share.
|
||||
let mut g = dst.clone();
|
||||
for c in g.children.iter_mut() {
|
||||
if let Node::Prop(d) = c
|
||||
&& let Some(sv) = src.get(&d.match_id)
|
||||
{
|
||||
d.value = sv.value.clone();
|
||||
d.keys = sv.keys.clone();
|
||||
d.expr = sv.expr.clone();
|
||||
}
|
||||
}
|
||||
replace_material(l, g);
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"assigned": n}))
|
||||
}
|
||||
|
||||
fn material_reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "material.revealSource")?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let LayerSource::Model { item } = l.source else { return Err(bad("material.revealSource", "the layer's material is its own (not from a model file)")) };
|
||||
s.state.project_selection = vec![item];
|
||||
Ok(json!({"item": item.0}))
|
||||
}
|
||||
|
||||
fn has_model_or_layers(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.newModel",
|
||||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
cmd!(
|
||||
"layer.new3dPrimitive",
|
||||
"3D Primitive",
|
||||
[],
|
||||
None,
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
|
||||
has_comp,
|
||||
new_primitive
|
||||
),
|
||||
cmd!(
|
||||
"layer.newPrimitive",
|
||||
"New 3D Primitive",
|
||||
[],
|
||||
None,
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
|
||||
has_comp,
|
||||
new_primitive
|
||||
),
|
||||
cmd!("comp.renderer", "Renderer", [], None, "{renderer?: advanced3d|classic3d (omit to read), value?}", has_comp, comp_renderer),
|
||||
cmd!("layer.environment", "Environment Layer", ["Layer"], None, "{layers?, on?}", has_layers, environment),
|
||||
cmd!(
|
||||
"material.set",
|
||||
"Material Options",
|
||||
[],
|
||||
None,
|
||||
"{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}",
|
||||
has_layers,
|
||||
material_set
|
||||
),
|
||||
cmd!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], None, "{layer?}", has_layers, material_reveal),
|
||||
cmd!("material.reset", "Reset Material", ["Layer", "Material"], None, "{layers?}", has_model_or_layers, material_reset),
|
||||
cmd!(
|
||||
"material.duplicateAssign",
|
||||
"Duplicate and Assign Material",
|
||||
["Layer", "Material"],
|
||||
None,
|
||||
"{layers? (first = source)}",
|
||||
has_layers,
|
||||
material_duplicate
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
proj.items.remove(id);
|
||||
}
|
||||
let comp_ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in comp_ids {
|
||||
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i))));
|
||||
if !uses {
|
||||
continue;
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
let gone: Vec<effectcraft_project::LayerId> =
|
||||
comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect();
|
||||
comp.layers.retain(|l| !gone.contains(&l.id));
|
||||
for l in &mut comp.layers {
|
||||
if l.parent.is_some_and(|x| gone.contains(&x)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.render_queue.retain(|r| !doomed.contains(&r.comp));
|
||||
st.project_selection.retain(|i| !doomed.contains(i));
|
||||
Ok(())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
Ok(json!(n))
|
||||
}
|
||||
|
||||
/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and
|
||||
/// a numbered name; the copies are selected.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.duplicate";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut created = vec![];
|
||||
s.edit("Duplicate", None, |proj, st| {
|
||||
for id in &ids {
|
||||
let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone();
|
||||
if it.is_folder() {
|
||||
return Err(bad(c, "folders can't be duplicated"));
|
||||
}
|
||||
let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string();
|
||||
let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default();
|
||||
let mut kind = it.kind.clone();
|
||||
if let effectcraft_project::ItemKind::Comp(comp) = &mut kind {
|
||||
let comp = std::sync::Arc::make_mut(comp);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for l in &mut comp.layers {
|
||||
let new = effectcraft_project::LayerId(proj.alloc());
|
||||
map.insert(l.id, new);
|
||||
l.id = new;
|
||||
let mut next = proj.next_id;
|
||||
l.props.reassign_uids(&mut next);
|
||||
proj.next_id = next + 1;
|
||||
}
|
||||
for l in &mut comp.layers {
|
||||
l.parent = l.parent.and_then(|p| map.get(&p).copied());
|
||||
if let Some(m) = &mut l.track_matte {
|
||||
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nid = proj.add_item(&name, it.label, it.parent, kind);
|
||||
created.push(nid);
|
||||
}
|
||||
st.project_selection = created.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": created.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select),
|
||||
@@ -147,5 +233,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items),
|
||||
cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label),
|
||||
cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment),
|
||||
cmd!(
|
||||
"project.delete",
|
||||
"Delete Project Items",
|
||||
[],
|
||||
None,
|
||||
"{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Generic edits of property-tree groups by uid: rename, enable, remove, duplicate and reorder an
|
||||
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
|
||||
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
|
||||
fn is_instance(g: &PropGroup) -> bool {
|
||||
matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. })
|
||||
}
|
||||
|
||||
/// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance.
|
||||
fn group_ref(s: &Session, p: &Value, cmd: &str, need_instance: bool) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let uid = p.get("prop").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `prop` (group uid)"))?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
|
||||
if need_instance && !is_instance(g) {
|
||||
return Err(bad(cmd, format!("`{}` is a fixed group (only effects, masks, shape items and animators)", g.name)));
|
||||
}
|
||||
Ok((cid, lid, uid))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.renameGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad(c, "the name can't be empty"));
|
||||
}
|
||||
s.edit("Rename", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.name = name.clone();
|
||||
Ok(json!(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.setGroupEnabled";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let v = b_p(p, "value");
|
||||
s.edit("Toggle", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.enabled = v.unwrap_or(!g.enabled);
|
||||
Ok(json!(g.enabled))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Duplicate", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let Node::Group(mut g) = parent.children[i].clone() else { return Err(bad(c, "not a group")) };
|
||||
g.reassign_uids(&mut next);
|
||||
let new = g.uid;
|
||||
parent.children.insert(i + 1, Node::Group(g));
|
||||
proj.next_id = next + 1;
|
||||
Ok(json!({"prop": new}))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.moveGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (1-based)"))? as usize;
|
||||
s.edit("Reorder", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
if to < 1 || to > parent.children.len() {
|
||||
return Err(bad(c, format!("index {to} is out of range 1..{}", parent.children.len())));
|
||||
}
|
||||
let n = parent.children.remove(i);
|
||||
parent.children.insert(to - 1, n);
|
||||
Ok(json!(to))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("prop.renameGroup", "Rename Property Group", [], None, "{layer?, prop: uid, name}", has_layers, rename),
|
||||
cmd!("prop.setGroupEnabled", "Enable Property Group", [], None, "{layer?, prop: uid, value?}", has_layers, set_enabled),
|
||||
cmd!("prop.removeGroup", "Delete Property Group", [], None, "{layer?, prop: uid}", has_layers, remove),
|
||||
cmd!("prop.duplicateGroup", "Duplicate Property Group", [], None, "{layer?, prop: uid} → {prop: new uid}", has_layers, duplicate),
|
||||
cmd!("prop.moveGroup", "Reorder Property Group", [], None, "{layer?, prop: uid, index (1-based among its siblings)}", has_layers, move_to),
|
||||
]
|
||||
}
|
||||
@@ -29,11 +29,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"),
|
||||
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
|
||||
// Time (timeline milestone).
|
||||
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
|
||||
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
|
||||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
// Cameras / lights / materials / 3D views (3D milestone).
|
||||
@@ -46,9 +44,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
|
||||
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
|
||||
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
|
||||
stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"),
|
||||
stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"),
|
||||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
// Color management.
|
||||
|
||||
@@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
||||
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
||||
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
||||
if added == 0 {
|
||||
// `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")).
|
||||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
let uid = g.uid;
|
||||
|
||||
@@ -289,8 +289,25 @@ pub struct Session {
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScriptRequest<'a> {
|
||||
pub code: &'a str,
|
||||
/// File name shown in errors and `$.fileName` (`console` for the Script Console).
|
||||
pub name: &'a str,
|
||||
/// Run in the Script Console's persistent context (variables survive between runs).
|
||||
pub console: bool,
|
||||
}
|
||||
|
||||
/// Runs a script against the session and reports `{ok, result, output, error: {message, line,
|
||||
/// column, file} | null}`.
|
||||
pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session {
|
||||
@@ -324,6 +341,7 @@ impl Default for Session {
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,6 +614,8 @@ mod tests_mask_warp;
|
||||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_model3d;
|
||||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
|
||||
@@ -942,6 +942,7 @@ Window
|
||||
Preview | window.panel {"panel":"preview"} | Cmd+3
|
||||
Progress | window.unavailablePanel {"panel":"progress"}
|
||||
Properties | window.panel {"panel":"properties"}
|
||||
Script Console | window.panel {"panel":"scriptConsole"}
|
||||
Smoother | window.panel {"panel":"smoother"}
|
||||
Tools | window.panel {"panel":"tools"} | Cmd+1
|
||||
Tracker | window.panel {"panel":"tracker"}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Advanced 3D commands: model import → model layers, primitives, the renderer switch with
|
||||
//! Geometry Options, Environment Layer and Material commands (undo/redo, serde).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, LayerId, LayerSource, PrimitiveKind, Project, Renderer};
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Importer, Session};
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
format!("{}/../model/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
/// Loads models from disk (what `effectcraft-media` does in the app).
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
None
|
||||
}
|
||||
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
let dir = std::path::Path::new(&f.path).parent()?.to_path_buf();
|
||||
let bytes = std::fs::read(&f.path).ok()?;
|
||||
effectcraft_model::load(&f.path, &bytes, &|u| std::fs::read(dir.join(u)).ok()).ok().map(Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelImporter;
|
||||
|
||||
impl Importer for ModelImporter {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
effectcraft_model::format_of(path).ok_or("not a model")?;
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Model,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
native_rate: None,
|
||||
duration: Tick::ZERO,
|
||||
has_video: false,
|
||||
has_audio: false,
|
||||
alpha: Default::default(),
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: "glTF".into(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_gltf_and_add_model_layer() {
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [fixture("quad.gltf")]})).unwrap();
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
assert_eq!(s.project.item(ItemId(item)).unwrap().type_name(), "3D Model");
|
||||
// Adding the item to the comp makes a model layer (3D, fitted, with the clip popup).
|
||||
let id = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let l = layer(&s, id);
|
||||
assert_eq!(l.source, LayerSource::Model { item: ItemId(item) });
|
||||
assert!(l.switches.three_d);
|
||||
assert!(l.props.sub("masks").is_none() && l.props.sub("materialOptions").is_some());
|
||||
// Bounds 2 units tall → half the comp height.
|
||||
let scale = l.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
|
||||
assert!((scale - 90.0).abs() < 1e-6, "{scale}");
|
||||
let anim = l.props.prop("geometryOptions/animation").unwrap();
|
||||
assert_eq!(anim.value, KV::Enum(1));
|
||||
match &anim.ui {
|
||||
effectcraft_project::ParamUi::Popup { options } => assert_eq!(options, &vec!["None".to_string(), "Move".to_string()]),
|
||||
u => panic!("{u:?}"),
|
||||
}
|
||||
s.undo();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
s.redo();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
// layer.newModel imports and places in one step.
|
||||
let r = s.execute("layer.newModel", json!({"path": fixture("cube.obj")})).unwrap();
|
||||
assert!(r["layer"].as_u64().is_some());
|
||||
assert!(s.execute("layer.newModel", json!({"path": "/nonexistent/thing.png"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_commands() {
|
||||
let mut s = session();
|
||||
for k in PrimitiveKind::ALL {
|
||||
let r = s.execute("layer.new3dPrimitive", json!({"kind": k.label().to_lowercase()})).unwrap();
|
||||
let l = layer(&s, r["layer"].as_u64().unwrap());
|
||||
assert_eq!(l.source, LayerSource::Primitive { kind: k });
|
||||
assert_eq!(l.name, k.label());
|
||||
assert!(l.switches.three_d);
|
||||
assert!(l.props.sub("geometryOptions").is_some());
|
||||
}
|
||||
let r = s
|
||||
.execute("layer.newPrimitive", json!({"kind": "sphere", "radius": 80, "segments": 12, "baseColor": [1, 0, 0], "metallic": 100, "castsShadows": "on"}))
|
||||
.unwrap();
|
||||
let l = layer(&s, r["layer"].as_u64().unwrap());
|
||||
assert_eq!(l.props.prop("geometryOptions/radius").unwrap().value.as_f64(), 80.0);
|
||||
assert_eq!(l.props.prop("materialOptions/metallic").unwrap().value.as_f64(), 100.0);
|
||||
assert_eq!(l.props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
|
||||
assert!(s.execute("layer.newPrimitive", json!({"kind": "teapot"})).is_err());
|
||||
let n = s.active_comp().unwrap().layers.len();
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), n - 1);
|
||||
// Menu entries point at the real command now.
|
||||
assert!(s.is_enabled("layer.new3dPrimitive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_switch_adds_geometry_options() {
|
||||
let mut s = session();
|
||||
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
|
||||
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
let g = layer(&s, t);
|
||||
let g = g.props.sub("geometryOptions").unwrap();
|
||||
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
assert_eq!(ids, vec!["bevelStyle", "bevelDepth", "holeBevelDepth", "extrusionDepth"]);
|
||||
// New shape layers in the Advanced 3D comp get them too.
|
||||
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(layer(&s, sh).props.sub("geometryOptions").is_some());
|
||||
assert_eq!(s.execute("comp.renderer", json!({})).unwrap()["renderer"], "advanced3d");
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
|
||||
s.redo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
assert!(s.execute("comp.renderer", json!({"renderer": "raytraced"})).is_err());
|
||||
// Extrusion values are ordinary properties.
|
||||
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
|
||||
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_layer_toggle() {
|
||||
let mut s = session();
|
||||
let id = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.environment", json!({"layer": id})).unwrap();
|
||||
assert!(layer(&s, id).environment && layer(&s, id).switches.three_d);
|
||||
s.execute("layer.environment", json!({"layer": id})).unwrap();
|
||||
assert!(!layer(&s, id).environment);
|
||||
s.undo();
|
||||
assert!(layer(&s, id).environment);
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("layer.environment", json!({"layer": cam})).is_err());
|
||||
// An Environment light sources a layer.
|
||||
let li = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(layer(&s, li).props.prop("lightOptions/source").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_commands_and_serde() {
|
||||
let mut s = session();
|
||||
let a = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newPrimitive", json!({"kind": "torus"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("material.set", json!({"layers": [a], "baseColor": "#3366ff", "roughness": 20, "acceptsLights": false})).unwrap();
|
||||
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
|
||||
assert!(s.execute("material.set", json!({"layers": [a], "castsShadows": "sometimes"})).is_err());
|
||||
s.execute("material.duplicateAssign", json!({"layers": [a, b]})).unwrap();
|
||||
assert_eq!(layer(&s, b).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
|
||||
assert_eq!(layer(&s, b).props.prop("materialOptions/acceptsLights").unwrap().value, KV::Bool(false));
|
||||
s.execute("material.reset", json!({"layers": [a]})).unwrap();
|
||||
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 50.0);
|
||||
s.undo();
|
||||
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
|
||||
assert!(s.execute("material.revealSource", json!({"layer": a})).is_err());
|
||||
// The project (model and primitive sources, environment flag) round-trips through JSON.
|
||||
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.execute("material.revealSource", json!({"layer": m})).unwrap()["item"], item);
|
||||
let json = s.project.to_json();
|
||||
let back = Project::from_json(&json).unwrap();
|
||||
assert_eq!(&back, &*s.project);
|
||||
}
|
||||
@@ -227,3 +227,26 @@ fn project_with_paint_and_puppet_round_trips() {
|
||||
let v: Value = serde_json::from_str(&j).unwrap();
|
||||
assert!(v.to_string().contains("ec.paint.paint") && v.to_string().contains("ec.distort.puppet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liquify_stroke_adds_the_effect_and_warps() {
|
||||
let (mut s, id) = setup();
|
||||
// Layer (5, 30) is comp (55, 50). Before: opaque blue.
|
||||
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
|
||||
let r = s.execute("liquify.stroke", json!({"layer": id, "tool": "warp", "points": [[-20, 30], [10, 30]], "size": 40, "pressure": 100})).unwrap();
|
||||
assert_eq!(r["strokes"], 1);
|
||||
let l = layer(&s, id);
|
||||
let fx = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&fx.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.distort.liquify"));
|
||||
// The warp pulls transparent pixels from outside the layer over its left edge.
|
||||
assert!(px(&s, 0.0, 55, 50)[3] < 0.9, "{:?}", px(&s, 0.0, 55, 50));
|
||||
// A second stroke goes into the same effect.
|
||||
let r2 = s.execute("liquify.stroke", json!({"layer": id, "tool": "reconstruction", "points": [[0, 30]], "size": 60, "pressure": 100})).unwrap();
|
||||
assert_eq!(r2["strokes"], 2);
|
||||
assert_eq!(r2["effect"], r["effect"]);
|
||||
assert!(s.execute("liquify.stroke", json!({"layer": id, "tool": "smudge", "points": [[0, 0]]})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, id).effects().unwrap().groups().next().is_none());
|
||||
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
|
||||
}
|
||||
|
||||
@@ -39,3 +39,60 @@ fn move_into_folder_rename_label_comment_with_undo() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(comp).unwrap().comment, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_items() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap();
|
||||
// Duplicate: a new comp with fresh layer ids.
|
||||
let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap();
|
||||
let dup = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(dup).unwrap().name, "Inner 2");
|
||||
let a = s.project.comp(inner).unwrap().layers[0].id;
|
||||
let b = s.project.comp(dup).unwrap().layers[0].id;
|
||||
assert_ne!(a, b);
|
||||
// Delete: layers that use the item and its render items go too; undoable.
|
||||
s.execute("project.delete", json!({"items": ["Inner"]})).unwrap();
|
||||
assert!(s.project.item(inner).is_none());
|
||||
assert!(s.project.comp(outer).unwrap().layers.is_empty());
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.undo();
|
||||
assert!(s.project.item(inner).is_some());
|
||||
assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1);
|
||||
// Folders take their contents.
|
||||
let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap());
|
||||
s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap();
|
||||
s.execute("project.delete", json!({"items": [f.0]})).unwrap();
|
||||
assert!(s.project.item(dup).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_edits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let fx = |s: &Session| -> Vec<(u64, String, bool)> {
|
||||
s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect()
|
||||
};
|
||||
s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap();
|
||||
s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap();
|
||||
let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap();
|
||||
let v = fx(&s);
|
||||
assert_eq!(v.iter().map(|x| x.0).collect::<Vec<_>>(), [b, a, d]);
|
||||
assert_eq!(v[1].1, "Soft");
|
||||
assert!(!v[1].2);
|
||||
s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap();
|
||||
assert_eq!(fx(&s).len(), 2);
|
||||
// Fixed groups can't be removed.
|
||||
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
||||
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user