mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Merge remote-tracking branch 'upstream/main' into m2915-merge
This commit is contained in:
commit
4aaf9c4bb4
110 files changed
+7835
-425
No files matched your search
@@ -489,6 +489,8 @@ async fn run_work(w: Work, shared: &RotoShared) {
|
||||
// With the classic engine, Propagate leaves no saved tracking behind.
|
||||
*lock(&shared.saved) = Some(String::new());
|
||||
}
|
||||
// Freezing again keeps the frames frozen already (resolved once: it reads the whole frozen cache).
|
||||
let kept = if w.task == RotoTask::Freeze && !w.object_matte { fx::applying_frozen(&w.params, w.size, w.fps) } else { None };
|
||||
for (k, f) in w.frames.iter().enumerate() {
|
||||
if cancel() {
|
||||
let mut s = lock(&shared.state);
|
||||
@@ -524,7 +526,7 @@ async fn run_work(w: Work, shared: &RotoShared) {
|
||||
}
|
||||
}
|
||||
(RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(),
|
||||
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
|
||||
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, kept.as_deref(), &mut get, &cancel) {
|
||||
Some((a, b)) => {
|
||||
if let Some(img) = src.get(*f) {
|
||||
frozen.size = [img.width as usize, img.height as usize];
|
||||
|
||||
@@ -18,8 +18,8 @@ pub mod content_fill;
|
||||
mod create;
|
||||
mod edit;
|
||||
mod effect;
|
||||
pub use effect::CryptoProbeJob;
|
||||
pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
|
||||
pub use effect::{CryptoCoverage, CryptoProbeJob};
|
||||
pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
|
||||
pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
|
||||
@@ -555,7 +555,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_keys,
|
||||
exponential_scale
|
||||
),
|
||||
cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector),
|
||||
cmd!(
|
||||
"text.addSelector",
|
||||
"Add Text Selector",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
|
||||
has_text_layer,
|
||||
add_selector
|
||||
),
|
||||
cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators),
|
||||
cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal),
|
||||
]
|
||||
|
||||
@@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["deleted"] = json!(settings.deleted.len());
|
||||
out["lensDistortionSolved"] = json!(settings.lens_distortion);
|
||||
let solve = ct::solve(¶ms);
|
||||
out["solved"] = json!(solve.is_some());
|
||||
// The solver interpolates the frames it could not solve: only a solve of every frame is
|
||||
// `solved` (what `camera.exportBlender` needs); one with gaps is `partial`.
|
||||
let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count());
|
||||
let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len());
|
||||
out["solved"] = json!(complete);
|
||||
out["partial"] = json!(solve.is_some() && !complete);
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
out["methodUsed"] = json!(sv.method_used.label());
|
||||
out["averageError"] = json!(sv.average_error);
|
||||
out["points"] = json!(sv.points.len());
|
||||
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
|
||||
out["solvedFrames"] = json!(solved_frames);
|
||||
out["focalLength"] = json!(f);
|
||||
out["horizontalAngleOfView"] = json!(sv.hfov(f));
|
||||
out["groundPlane"] = json!(sv.ground.is_some());
|
||||
|
||||
@@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoPr
|
||||
})))
|
||||
}
|
||||
|
||||
/// [`Session::crypto_coverage`].
|
||||
pub(crate) fn crypto_coverage(s: &Session, p: &Value, hash: u32, max_side: usize) -> Option<CryptoCoverage> {
|
||||
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt").ok()?;
|
||||
let probe = cached_probe(s, key)?;
|
||||
let b = probe.grid.as_ref().ok()?;
|
||||
let (aw, ah) = (b.aux.width as usize, b.aux.height as usize);
|
||||
if aw == 0 || ah == 0 {
|
||||
return None;
|
||||
}
|
||||
let step = aw.max(ah).div_ceil(max_side.max(1)).max(1);
|
||||
let (w, h) = (aw.div_ceil(step), ah.div_ceil(step));
|
||||
let planes = b.aux.crypto_rank_planes(&b.layer);
|
||||
let sample = |j: usize| {
|
||||
planes.iter().filter(|(id, _)| id.get(j).is_some_and(|v| v.to_bits() == hash)).map(|(_, c)| c.get(j).map_or(0.0, |c| c.max(0.0))).sum::<f32>().min(1.0)
|
||||
};
|
||||
// Each sample at the centre of its step × step cell; the last cells are cut by the layer.
|
||||
let at = |x: usize, y: usize| sample((y * step + step / 2).min(ah - 1) * aw + (x * step + step / 2).min(aw - 1));
|
||||
let coverage = (0..w * h).map(|i| at(i % w, i / w)).collect();
|
||||
let (size, uv) = ([aw as f64 / b.aux.scale, ah as f64 / b.aux.scale], [aw as f64 / (w * step) as f64, ah as f64 / (h * step) as f64]);
|
||||
Some(CryptoCoverage { width: w, height: h, coverage, size, uv })
|
||||
}
|
||||
|
||||
/// An ID's coverage over a layer, for the eyedropper's hover highlight: `width` × `height`
|
||||
/// samples (row-major, 0..1). The layer, `size` effect-space pixels from its origin, shows the
|
||||
/// texture from (0, 0) to `uv`.
|
||||
pub struct CryptoCoverage {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub coverage: Vec<f32>,
|
||||
pub size: [f64; 2],
|
||||
pub uv: [f64; 2],
|
||||
}
|
||||
|
||||
/// What a pick finds at a point.
|
||||
enum Hit {
|
||||
/// `probeOnly` and the probe isn't built yet.
|
||||
|
||||
@@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// "Pre-comp N" with the lowest N no item already uses.
|
||||
fn unused_precomp_name(proj: &effectcraft_project::Project) -> String {
|
||||
(1u32..).map(|n| format!("Pre-comp {n}")).find(|n| !proj.items.values().any(|i| &i.name == n)).unwrap_or_else(|| "Pre-comp".to_string())
|
||||
}
|
||||
|
||||
/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition")
|
||||
/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage,
|
||||
/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's
|
||||
@@ -212,7 +217,10 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(c, "no layers"));
|
||||
}
|
||||
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
|
||||
let name = match str_p(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => unused_precomp_name(&s.project),
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let leave = match str_p(p, "mode").unwrap_or("move") {
|
||||
"move" => false,
|
||||
@@ -280,6 +288,15 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
|
||||
c.layers.retain(|l| !ids.contains(&l.id));
|
||||
// Layers left behind must not keep pointing at layers that moved into the new comp.
|
||||
for l in &mut c.layers {
|
||||
if l.parent.is_some_and(|p| ids.contains(&p)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.as_ref().is_some_and(|m| ids.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
let lid = l.id;
|
||||
c.layers.insert(at.min(c.layers.len()), l);
|
||||
st.selected_layers = vec![lid];
|
||||
|
||||
@@ -171,9 +171,29 @@ fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
|
||||
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;
|
||||
/// The Material Options parameters [`apply_material`] reads. Which ones a layer has depends on
|
||||
/// its kind (see [`material_mismatch`]).
|
||||
const MATERIAL_PARAMS: [&str; 14] = [
|
||||
"castsShadows",
|
||||
"acceptsShadows",
|
||||
"acceptsLights",
|
||||
"appearsInReflections",
|
||||
"baseColor",
|
||||
"metallic",
|
||||
"roughness",
|
||||
"emissive",
|
||||
"lightTransmission",
|
||||
"ambient",
|
||||
"diffuse",
|
||||
"specularIntensity",
|
||||
"specularShininess",
|
||||
"metal",
|
||||
];
|
||||
|
||||
/// Parse Material Options parameters onto a layer at comp time `t`. The parameters the layer
|
||||
/// had a property for.
|
||||
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<Vec<&'static str>> {
|
||||
let mut set = Vec::new();
|
||||
let tri = |v: &Value| -> Option<u32> {
|
||||
match v {
|
||||
Value::Bool(b) => Some(*b as u32),
|
||||
@@ -187,28 +207,55 @@ fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize>
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut put = |l: &mut Layer, k: &'static str, v: KV| {
|
||||
if set_at(l, &format!("materialOptions/{k}"), t, v) {
|
||||
set.push(k);
|
||||
}
|
||||
};
|
||||
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;
|
||||
put(l, k, KV::Enum(e));
|
||||
}
|
||||
}
|
||||
// A value of the wrong type is an error, not a parameter left out.
|
||||
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;
|
||||
if p.get(k).is_some() {
|
||||
let v = b_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: true|false")))?;
|
||||
put(l, k, KV::Bool(v));
|
||||
}
|
||||
}
|
||||
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;
|
||||
if p.get(k).is_some() {
|
||||
let v = f_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: a number")))?;
|
||||
put(l, k, KV::Scalar(v));
|
||||
}
|
||||
}
|
||||
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;
|
||||
if p.get(k).is_some() {
|
||||
let c = color_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: \"#rrggbb\" or [r, g, b]")))?;
|
||||
put(l, k, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0]));
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// The error for Material Options parameters `missing` that none of the layers has: what the
|
||||
/// first layer has instead, and which layers the missing ones belong to.
|
||||
fn material_mismatch(l: Option<&Layer>, missing: &[&str]) -> EngineError {
|
||||
let names = missing.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", ");
|
||||
let Some(l) = l else { return bad("material.set", format!("no layer has {names}")) };
|
||||
let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options, so it has no {names}", l.name)) };
|
||||
let has: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
let why = if matches!(l.source, LayerSource::Model { .. }) { ": a 3D model layer keeps its model file's materials" } else { "" };
|
||||
bad(
|
||||
"material.set",
|
||||
format!(
|
||||
"`{}` has no {names}{why}. Its Material Options: {}. baseColor, metallic, roughness and emissive are for 3D primitive layers; lightTransmission, ambient, diffuse, specularIntensity, specularShininess and metal for footage, solid, text, shape and precomp layers",
|
||||
l.name,
|
||||
has.join(", ")
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -273,19 +320,22 @@ fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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, …)",
|
||||
));
|
||||
if !MATERIAL_PARAMS.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad("material.set", format!("no Material Options parameter given ({})", MATERIAL_PARAMS.join(", "))));
|
||||
}
|
||||
// A parameter none of the layers has fails the whole command (the edit is dropped), so
|
||||
// it is never skipped without a word.
|
||||
let n = s.edit("Material Options", None, |proj, _| {
|
||||
let mut set = Vec::new();
|
||||
for id in &ids {
|
||||
set.extend(apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?);
|
||||
}
|
||||
let missing: Vec<&str> = MATERIAL_PARAMS.into_iter().filter(|k| p.get(*k).is_some() && !set.contains(k)).collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(material_mismatch(ids.first().and_then(|id| proj.comp(cid)?.layer(*id)), &missing));
|
||||
}
|
||||
Ok(set.len())
|
||||
})?;
|
||||
Ok(json!({"set": n}))
|
||||
}
|
||||
|
||||
@@ -411,7 +461,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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?}",
|
||||
"{layers?, time?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections? (model and primitive layers), baseColor?, metallic?, roughness?, emissive? (primitive layers; a model layer keeps its file's materials), lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal? (footage, solid, text, shape and precomp layers)}; a parameter none of the layers has is an error",
|
||||
has_layers,
|
||||
material_set
|
||||
),
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//!
|
||||
//! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is
|
||||
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
|
||||
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
|
||||
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off
|
||||
//! every mesh a pin starts a new mesh, unless its art already has one (#2865).
|
||||
|
||||
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
|
||||
use effectcraft_effects::{Buf, Params};
|
||||
@@ -62,6 +63,32 @@ fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result<Tick> {
|
||||
Ok(l.layer_time(t))
|
||||
}
|
||||
|
||||
/// Whether a pin at `at` (effect space) goes on this mesh as `def` deforms it: on it or beside it.
|
||||
fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool {
|
||||
!mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0)
|
||||
}
|
||||
|
||||
/// Whether a new mesh at `at` (effect space) would repeat a mesh of `params`: the art it would
|
||||
/// outline is that mesh's own, even if a deformation has carried the mesh off it (#2865).
|
||||
fn repeats_mesh(buf: &Buf, params: &Params, at: [f64; 2]) -> bool {
|
||||
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
|
||||
let alpha: Vec<f32> = buf.img.data.iter().map(|p| p[3]).collect();
|
||||
puppet::parse(params).iter().any(|m| {
|
||||
let mut outline = puppet::mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, m.opts.expansion);
|
||||
// As `triangulate` winds it.
|
||||
if puppet::mesh::signed_area(&outline) < 0.0 {
|
||||
outline.reverse();
|
||||
}
|
||||
puppet::mesh_for(buf, m).outline == outline
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect.
|
||||
pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool {
|
||||
let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true };
|
||||
!repeats_mesh(&buf, ¶ms, at) || puppet::overlay(&buf, ¶ms).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at))
|
||||
}
|
||||
|
||||
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "puppet.addPin";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
@@ -93,9 +120,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inside = puppet::contains(mesh, def, at);
|
||||
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
|
||||
if inside || near || want_mesh.is_some() {
|
||||
if on_mesh(mesh, def, at) || want_mesh.is_some() {
|
||||
let rest = puppet::unmap(mesh, def, at);
|
||||
// A moving pin holds its nearest mesh vertex at `position + (vertex - rest)`
|
||||
// (puppet::solve). Choose the position that keeps that vertex where it is now, so
|
||||
@@ -120,6 +145,9 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, format!("no mesh {w}")));
|
||||
}
|
||||
if target.is_none() && repeats_mesh(&buf, ¶ms, at) {
|
||||
return Ok(json!({"pin": null, "changed": false}));
|
||||
}
|
||||
}
|
||||
let opts = MeshOpts {
|
||||
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
|
||||
|
||||
@@ -49,8 +49,9 @@ fn per_char_on(l: &Layer) -> bool {
|
||||
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
|
||||
}
|
||||
|
||||
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
|
||||
/// a selected property, else the last one.
|
||||
/// The animator a command targets: `animator` (uid or 1-based index; a uid match wins, so an
|
||||
/// animator whose uid equals an index is picked over the one at that position), else the animator
|
||||
/// holding a selected property, else the last one.
|
||||
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
|
||||
let list: Vec<&PropGroup> = anims.groups().collect();
|
||||
@@ -59,7 +60,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
||||
if let Some(g) = list.iter().find(|g| g.uid == n) {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
|
||||
let count = if list.len() == 1 { "1 animator".to_string() } else { format!("{} animators", list.len()) };
|
||||
return list
|
||||
.get((n as usize).wrapping_sub(1))
|
||||
.map(|g| g.uid)
|
||||
.ok_or_else(|| bad(cmd, format!("no animator {n}: `animator` is a uid or a 1-based index, and the layer has {count}")));
|
||||
}
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
if *lid != l.id {
|
||||
@@ -454,7 +459,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Property",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), property: (see layer.addTextAnimator)}",
|
||||
has_animator,
|
||||
add_animator_prop
|
||||
),
|
||||
@@ -463,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Text Selector",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
|
||||
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
|
||||
has_animator,
|
||||
add_selector
|
||||
),
|
||||
@@ -482,7 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Variable Font Axes",
|
||||
["Animation", "Animate Text"],
|
||||
None,
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based; a uid match wins over an index)}",
|
||||
has_text_layer,
|
||||
font_axes
|
||||
),
|
||||
|
||||
@@ -36,6 +36,13 @@ impl Session {
|
||||
commands::crypto_probe_job(self, p)
|
||||
}
|
||||
|
||||
/// The coverage of Cryptomatte ID `hash` (as `effect.cryptomatteAt` reports it, parsed) over
|
||||
/// the layer of params `p`, at most `max_side` samples on the long side, from the kept probe:
|
||||
/// `None` until [`Session::crypto_probe_job`] has built it.
|
||||
pub fn crypto_coverage(&self, p: &Value, hash: u32, max_side: usize) -> Option<commands::CryptoCoverage> {
|
||||
commands::crypto_coverage(self, p, hash, max_side)
|
||||
}
|
||||
|
||||
/// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
|
||||
/// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
|
||||
pub fn drop_crypto_probe(&self) {
|
||||
|
||||
@@ -238,6 +238,23 @@ fn blender_export_rejects_malformed_solves_without_panicking() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A solve with unsolved (interpolated) frames is partial, not solved, so the status agrees with
|
||||
/// the export, which refuses it.
|
||||
#[test]
|
||||
fn solve_status_reports_unsolved_frames_as_partial() {
|
||||
let (mut s, lid, uid) = fixture(false);
|
||||
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
|
||||
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(true), &json!(false), &json!(3)), "{st}");
|
||||
let mut solve =
|
||||
ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())).unwrap().as_ref().clone();
|
||||
solve.frames[1].solved = false;
|
||||
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||||
s.camera_pending.clear();
|
||||
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
|
||||
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(false), &json!(true), &json!(2)), "{st}");
|
||||
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("partial.py")})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_respects_script_write_permission() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
|
||||
@@ -431,7 +431,13 @@ fn hover_latency() {
|
||||
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
|
||||
// The highlight of the hovered ID (built once per ID, at most 1024 samples wide).
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
|
||||
let hash = u32::from_str_radix(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["hash"].as_str().unwrap(), 16).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
|
||||
let cov = t0.elapsed().as_secs_f64() * 1e3;
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms; highlight {}x{} {cov:.2} ms", ms[0], ms[1..].join(" / "), c.width, c.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,3 +800,99 @@ fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
|
||||
}
|
||||
|
||||
/// The hover highlight: an ID's coverage from the kept probe, one sample at the centre of each
|
||||
/// cell of aux pixels, over the layer's extent (the last cells cut by it).
|
||||
#[test]
|
||||
fn coverage_of_an_id_from_the_kept_probe() {
|
||||
let s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
let left = crypto_hash("left");
|
||||
assert!(s.crypto_coverage(&q, left, 64).is_none(), "no coverage before the probe");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
let c = s.crypto_coverage(&q, left, 64).unwrap();
|
||||
assert_eq!((c.width, c.height, c.size, c.uv), (16, 8, [8.0, 4.0], [1.0, 1.0]), "the aux pixels over the layer");
|
||||
assert!(c.coverage.iter().all(|v| (0.0..=1.0).contains(v)) && c.coverage.iter().any(|v| *v > 0.5) && c.coverage.contains(&0.0), "{:?}", c.coverage);
|
||||
assert_eq!(c.coverage[0], 0.6, "left's coverage at the first aux pixel");
|
||||
assert!(s.crypto_coverage(&q, crypto_hash("nobody"), 64).unwrap().coverage.iter().all(|v| *v == 0.0));
|
||||
assert_eq!(s.crypto_coverage(&q, left, 2).map(|c| (c.width, c.height)), Some((2, 1)));
|
||||
// An odd size: 2 × 2 cells, the last ones half outside the layer; samples at cell centres.
|
||||
let mut s = cells_session(1919, 1079, false);
|
||||
let q = json!({"effect": "Cryptomatte", "x": 40.0, "y": 10.0});
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "c1_0");
|
||||
let c = s.crypto_coverage(&q, crypto_hash("c1_0"), 1024).unwrap();
|
||||
assert_eq!((c.width, c.height, c.size), (960, 540, [1919.0, 1079.0]));
|
||||
assert_eq!(c.uv, [1919.0 / 1920.0, 1079.0 / 1080.0]);
|
||||
// Sample 18 covers pixels 36-37: its centre (37) is in c1_0, its corner (36) in c0_0.
|
||||
assert_eq!((c.coverage[17], c.coverage[18], c.coverage[37]), (0.0, 1.0, 0.0));
|
||||
}
|
||||
|
||||
struct Ranked(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
|
||||
impl FootageSource for Ranked {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(
|
||||
self.2
|
||||
.get_or_init(|| {
|
||||
let (w, h) = (self.0 as usize, self.1 as usize);
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
for part in 0..3 {
|
||||
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
let rank = part * 2 + k / 2;
|
||||
let v: Vec<f32> = (0..w * h)
|
||||
.map(|i| if k % 2 == 0 { crypto_float(crypto_hash(&format!("r{rank}_{}", Cells::name(i % w, i / w)))) } else { 1.0 / 6.0 })
|
||||
.collect();
|
||||
aux.channels.push((format!("CryptoObject{part:02}.{c}"), v));
|
||||
}
|
||||
}
|
||||
Arc::new(aux)
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn rss_mb() -> f64 {
|
||||
let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
||||
s.lines().find(|l| l.starts_with("VmRSS")).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) / 1024.0
|
||||
}
|
||||
|
||||
/// Highlight cost with 6 ranks (Cryptomatte's default 3 parts) at 1080p, 4K and 8K, and the memory the
|
||||
/// probe pins. Run with `--ignored --nocapture`; it measures, it doesn't assert.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn highlight_cost_with_six_ranks() {
|
||||
for (w, h) in [(1920u32, 1080u32), (3840, 2160), (7680, 4320)] {
|
||||
let r0 = rss_mb();
|
||||
let mut s = Session { footage: Arc::new(Ranked(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/ranked.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("effect.cryptomatteAt", q.clone()).unwrap();
|
||||
let build = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let hash = u32::from_str_radix(r["hash"].as_str().unwrap(), 16).unwrap();
|
||||
let mut ms = vec![];
|
||||
for _ in 0..5 {
|
||||
let t0 = std::time::Instant::now();
|
||||
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
|
||||
ms.push(t0.elapsed().as_secs_f64() * 1e3);
|
||||
assert!(c.coverage.iter().any(|v| *v > 0.0));
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
|
||||
let bytes: usize = aux.channels.iter().map(|(_, v)| v.len() * 4).sum();
|
||||
eprintln!(
|
||||
"HIGHLIGHT 6 ranks {w}x{h}: aux {:.0} MB ({} planes), probe build {build:.1} ms, highlight median {:.2} ms (min {:.2}), RSS +{:.0} MB",
|
||||
bytes as f64 / 1e6,
|
||||
aux.channels.len(),
|
||||
ms[2],
|
||||
ms[0],
|
||||
rss_mb() - r0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2035,3 +2035,33 @@ fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() {
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mk = |s: &mut Session, name: &str| {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D"));
|
||||
s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!(comp.layers.len(), 3);
|
||||
for l in &comp.layers {
|
||||
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name);
|
||||
assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name);
|
||||
}
|
||||
s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap();
|
||||
let mut names: Vec<String> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp"))
|
||||
.map(|i| i.name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
|
||||
}
|
||||
@@ -232,6 +232,43 @@ fn material_commands_and_serde() {
|
||||
assert_eq!(&back, &*s.project);
|
||||
}
|
||||
|
||||
/// #2917: a 3D model layer keeps its file's materials, so it has no Base Color, Metallic or
|
||||
/// Roughness. material.set refused them with "no Material Options property matched (…,
|
||||
/// baseColor, metallic, roughness, …)", naming the very parameters it refused, and next to a
|
||||
/// parameter the layer does have it dropped them without a word.
|
||||
#[test]
|
||||
fn material_set_names_the_parameters_a_layer_lacks() {
|
||||
let mut s = session();
|
||||
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();
|
||||
let e = s.execute_checked("material.set", json!({"layers": [m], "baseColor": "#3366ff", "roughness": 20})).unwrap_err().to_string();
|
||||
assert!(e.contains("`baseColor`, `roughness`") && e.contains("model file") && e.contains("appearsInReflections"), "{e}");
|
||||
assert!(!e.contains("`metallic`"), "{e}");
|
||||
// A parameter the layer has doesn't hide one it lacks, and nothing is set.
|
||||
assert!(s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on", "metallic": 50})).is_err());
|
||||
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(0));
|
||||
s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on"})).unwrap();
|
||||
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
|
||||
// Across layers, a parameter goes to the ones that have it.
|
||||
let cube = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.execute_checked("material.set", json!({"layers": [m, cube], "roughness": 20})).unwrap()["set"], 1);
|
||||
assert_eq!(layer(&s, cube).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
|
||||
// A solid has the classic shading, not the primitive's material.
|
||||
let solid = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
|
||||
let e = s.execute_checked("material.set", json!({"layers": [solid], "emissive": "#ffffff"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`emissive`") && e.contains("specularShininess"), "{e}");
|
||||
assert!(s.execute_checked("material.set", json!({"layers": [m]})).is_err());
|
||||
// The parameter list still names every parameter, or agents' calls are refused before they run.
|
||||
let spec = crate::command_specs().iter().find(|c| c.id == "material.set").unwrap();
|
||||
let keys = crate::commands::accepted_params(spec.params).unwrap();
|
||||
let want = "layers time castsShadows acceptsShadows acceptsLights appearsInReflections baseColor metallic roughness emissive \
|
||||
lightTransmission ambient diffuse specularIntensity specularShininess metal";
|
||||
for k in want.split_whitespace() {
|
||||
assert!(keys.iter().any(|a| a == k), "{k}: {keys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an
|
||||
/// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo
|
||||
/// step, with a toast), as After Effects does.
|
||||
|
||||
@@ -225,6 +225,71 @@ fn a_pin_added_to_a_bent_mesh_moves_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh_count(s: &mut Session, id: u64) -> usize {
|
||||
s.execute("puppet.info", json!({"layer": id})).unwrap()["meshes"].as_array().unwrap().len()
|
||||
}
|
||||
|
||||
/// #2865: where the deformed layer is empty a click makes nothing: no copy of the art it has moved
|
||||
/// off, no undo step, no error.
|
||||
#[test]
|
||||
fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() {
|
||||
let (mut s, id) = setup();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
|
||||
s.set_time(Tick::from_seconds_f64(1.0));
|
||||
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 1", "position": [10, 70]})).unwrap();
|
||||
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 70]})).unwrap();
|
||||
let (cid, t) = (s.active_comp_id().unwrap(), Tick::from_seconds_f64(1.0));
|
||||
let before = s.render(cid, t, RenderOpts::default());
|
||||
let revision = s.revision;
|
||||
let lid = effectcraft_project::LayerId(id);
|
||||
// The solid's top, which the pins have carried 40 px down; and well off it.
|
||||
for at in [[50, 10], [300, 300]] {
|
||||
assert!(!crate::commands::puppet::places_pin(&s, cid, lid, [at[0] as f64, at[1] as f64], t), "{at:?}");
|
||||
let r = s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
|
||||
assert_eq!(r["pin"], Value::Null, "{at:?}");
|
||||
assert_eq!(s.revision, revision, "{at:?} changed the project");
|
||||
}
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2));
|
||||
assert_eq!(s.render(cid, t, RenderOpts::default()), before);
|
||||
// Whatever Expansion new meshes would now get, the art is the mesh's own.
|
||||
s.state.puppet.expansion = 9.0;
|
||||
assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 10]})).unwrap()["pin"], Value::Null);
|
||||
// On the deformed shape a pin still goes on the mesh.
|
||||
assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t));
|
||||
assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64());
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3));
|
||||
}
|
||||
|
||||
/// #2865: separate art still gets its own mesh, also from a near miss in the gap beside it (a click
|
||||
/// off the art finds the art nearest it): only a repeat of a mesh is refused.
|
||||
#[test]
|
||||
fn separate_art_still_gets_its_own_mesh() {
|
||||
let (mut s, id) = setup();
|
||||
for x in [[0, 30], [60, 100]] {
|
||||
s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap();
|
||||
}
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1));
|
||||
// In the gap, nearer the right piece and past reach of the left piece's mesh.
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the near miss meshes the right piece");
|
||||
s.undo();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh");
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it");
|
||||
}
|
||||
|
||||
/// `newMesh` asks for a new mesh wherever the click is, the art's own mesh or not.
|
||||
#[test]
|
||||
fn new_mesh_option_starts_a_mesh_on_meshed_art() {
|
||||
let (mut s, id) = setup();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
|
||||
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30], "newMesh": true})).unwrap();
|
||||
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_pin_kinds_and_mesh_options() {
|
||||
let (mut s, id) = setup();
|
||||
|
||||
@@ -49,6 +49,38 @@ fn animate_text_adds_animators_with_companion_properties() {
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// `animator` counts from 1, as `#n` references do, and a uid match wins over an index: every
|
||||
/// command that takes it says so, and 0 fails with an error that says so (agents reading
|
||||
/// `uid|index` tried 0).
|
||||
#[test]
|
||||
fn animator_indices_count_from_1_and_say_so() {
|
||||
let (mut s, t) = session();
|
||||
let uid = s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap()["animator"].as_u64().unwrap();
|
||||
for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.addSelector", "text.animatorFontAxes"] {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
assert!(spec.params.contains("animator?: uid|index (1-based; a uid match wins over an index)"), "{id}: {}", spec.params);
|
||||
}
|
||||
for (id, p) in [
|
||||
("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 0, "property": "fillHue"})),
|
||||
("layer.addTextSelector", json!({"layer": t, "animator": 0, "kind": "wiggly"})),
|
||||
] {
|
||||
let e = s.execute(id, p).unwrap_err().to_string();
|
||||
assert!(e.contains("no animator 0") && e.contains("1-based"), "{id}: {e}");
|
||||
}
|
||||
let r = s.execute("layer.addTextSelector", json!({"layer": t, "animator": 1, "kind": "wiggly"})).unwrap();
|
||||
assert_eq!(r["animator"].as_u64(), Some(uid));
|
||||
// A second animator whose uid is 1: `animator: 1` is that uid, not the first animator, and
|
||||
// index 2 reaches the same animator.
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "opacity"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(t)).unwrap();
|
||||
layer.props.group_mut("text/animators").unwrap().children[1].as_group_mut().unwrap().uid = 1;
|
||||
for (id, animator) in [("layer.addTextSelector", 1), ("text.addSelector", 1), ("layer.addTextSelector", 2)] {
|
||||
let r = s.execute(id, json!({"layer": t, "animator": animator, "kind": "wiggly"})).unwrap();
|
||||
assert_eq!(r["animator"].as_u64(), Some(1), "{id} animator {animator}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_property_and_selectors_with_undo_redo() {
|
||||
let (mut s, t) = session();
|
||||
|
||||
@@ -60,6 +60,62 @@ fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||||
}
|
||||
|
||||
/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win
|
||||
/// and leave the other axis free. Two lines do win when together they are nearer.
|
||||
#[test]
|
||||
fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() {
|
||||
let src = SnapSource::Layer(LayerId(1));
|
||||
let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src);
|
||||
let top = SnapTarget::hline(50.0, PRI_LAYER, src);
|
||||
// The line alone is nearer (1 against √10), and the point still snaps both axes.
|
||||
let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap();
|
||||
assert_eq!(s.at, [100.0, 50.0]);
|
||||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||||
assert_eq!(s.hits, [mid]);
|
||||
// Lines through the point itself tie with it: the point.
|
||||
let centre = SnapTarget::vline(100.0, PRI_LAYER, src);
|
||||
assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]);
|
||||
// Two lines together nearer than the point: both snap.
|
||||
let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src);
|
||||
let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide);
|
||||
let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide);
|
||||
let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap();
|
||||
assert_eq!(s.at, [102.0, 52.0]);
|
||||
assert_eq!(s.hits, [v, h]);
|
||||
}
|
||||
|
||||
/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp
|
||||
/// through a collapsed precomp layer, and a rotated layer keeps its points only.
|
||||
#[test]
|
||||
fn layer_centre_lines_follow_the_edges() {
|
||||
use crate::viewer::{SnapFeatures, SnapOptions, targets};
|
||||
let mut s = comp();
|
||||
// A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80).
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let at = |s: &Session, f: SnapFeatures| {
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
|
||||
};
|
||||
let lines = |t: &[SnapTarget], l: LayerId| {
|
||||
let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span);
|
||||
(of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0))
|
||||
};
|
||||
assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None)));
|
||||
let off = SnapFeatures { edges_extended: false, ..Default::default() };
|
||||
assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
|
||||
// Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay).
|
||||
s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
|
||||
let pre = s.active_comp().unwrap().layers[0].id;
|
||||
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
|
||||
assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
|
||||
// Rotated: no lines at all.
|
||||
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
assert_eq!(lines(&at(&s, off), pre), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
let mut s = comp();
|
||||
|
||||
+12
-11
@@ -48,7 +48,7 @@ pub struct SnapTarget {
|
||||
pub priority: u8,
|
||||
pub source: SnapSource,
|
||||
/// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's
|
||||
/// edge without Snap Edges Extended. `None` runs across the whole comp.
|
||||
/// edge or centre line without Snap Edges Extended. `None` runs across the whole comp.
|
||||
#[serde(default)]
|
||||
pub span: Option<[f64; 2]>,
|
||||
}
|
||||
@@ -70,7 +70,7 @@ impl SnapTarget {
|
||||
}
|
||||
|
||||
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
|
||||
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
|
||||
/// (layer edge and centre lines snap along their whole line, beyond the layer's bounds) and Snap to Features in
|
||||
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
|
||||
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
|
||||
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
|
||||
@@ -126,9 +126,10 @@ fn better(a: (f64, u8), b: (f64, u8)) -> bool {
|
||||
}
|
||||
|
||||
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
|
||||
/// A point target snaps both axes and wins when it is at least as near as the best lines;
|
||||
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
|
||||
/// the lower priority value.
|
||||
/// A point target snaps both axes and wins unless a vertical and a horizontal line both resolve
|
||||
/// and together lie nearer than it (one line alone leaves the other axis free, so it never beats
|
||||
/// a point: a layer's edge midpoint lies on its edge line); otherwise the nearest vertical and
|
||||
/// horizontal lines snap x and y independently. Ties go to the lower priority value.
|
||||
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
|
||||
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
|
||||
@@ -150,9 +151,9 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
|
||||
}
|
||||
}
|
||||
}
|
||||
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
|
||||
let both_lines = best_x.zip(best_y).map_or(f64::INFINITY, |(x, y)| x.0.0.hypot(y.0.0));
|
||||
if let Some((k, s, t)) = best_pt
|
||||
&& k.0 <= line_best + 1e-9
|
||||
&& k.0 <= both_lines + 1e-9
|
||||
{
|
||||
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
|
||||
}
|
||||
@@ -366,8 +367,8 @@ fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterato
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the
|
||||
/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
|
||||
/// A 2D layer's snap targets: its box points, its edges and centre as lines while unrotated (across
|
||||
/// the comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
|
||||
/// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with
|
||||
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
|
||||
/// collapsed precomp layer. None for cameras, lights and 3D layers.
|
||||
@@ -386,10 +387,10 @@ fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, dep
|
||||
let [nw, ne, _, sw] = b.corners;
|
||||
if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
|
||||
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) };
|
||||
for x in [nw[0], ne[0]] {
|
||||
for x in [nw[0], b.center[0], ne[0]] {
|
||||
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
|
||||
}
|
||||
for y in [nw[1], sw[1]] {
|
||||
for y in [nw[1], b.center[1], sw[1]] {
|
||||
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user