Merge remote-tracking branch 'upstream/main' into m2915-merge

This commit is contained in:
vangel76 committed 2026-11-03 19:08:37 +01:00
commit 4aaf9c4bb4
110 files changed
+7835 -425

No files matched your search

+9 -1
View 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),
]
+7 -2
View File
@@ -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(&params);
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());
+33
View File
@@ -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.
+18 -1
View File
@@ -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];
+74 -24
View File
@@ -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
),
+32 -4
View File
@@ -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, &params, at) || puppet::overlay(&buf, &params).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, &params, at) {
return Ok(json!({"pin": null, "changed": false}));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
+11 -6
View File
@@ -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
),