Files
effectcraft/crates/engine/src/commands/liquify.rs
T
Brandon ThomasandClaude Opus 5.5 451f63cc0d M9.11: Blur & Sharpen and Distort effects (CC Radial Blur, Camera-Shake Deblur, CC Flo Motion, Liquify, Rolling Shutter Repair)
Liquify stores its strokes as effect data and replays them into a cached
displacement mesh; liquify.stroke / liquify.clear commands for agents.
Rolling Shutter Repair measures motion with effectcraft-track KLT (new
effects -> track same-layer edge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:08:04 -04:00

157 lines
7.3 KiB
Rust

//! 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),
]
}