Merge main into #2268; import both layers_or and popup_options, drop main's now-unreachable post-duplicate end_edit (the PR ends the edit first), adapt tests to main's kept empty Type-tool layer (f262e39c6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 10:23:03 +07:00
co-authored by Claude Opus 5.5
542 changed files with 37155 additions and 3140 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
+263 -60
View File
@@ -1,4 +1,4 @@
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked};
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
@@ -9,10 +9,20 @@ pub(crate) struct ParentFix {
rotation: f64,
/// Multiply Scale.
scale: [f64; 2],
/// 3D AV layers retain their world-space anchor, including Z.
/// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also
/// re-express their Point of Interest.
position_3d: Option<effectcraft_geom::Mat4>,
/// A uniform relative parent transform can preserve static Orientation and Scale.
pose_3d: Option<([f64; 3], f64)>,
/// A uniform relative parent transform re-expresses static Orientation and Scale.
pose_3d: Option<Pose3d>,
}
/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale.
#[derive(Clone, Copy)]
struct Pose3d {
/// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`.
correction: effectcraft_geom::Mat4,
/// Uniform scale factor, multiplied into Scale (negative for a mirrored parent).
factor: f64,
}
impl ParentFix {
@@ -22,7 +32,7 @@ impl ParentFix {
if let Some(m) = self.position_3d {
// Spatial Position speed measures distance along its path. Its parent-space
// unit changes even when the child's Orientation cannot be re-expressed.
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs());
let map_position = |v: &KV| {
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
KV::Vec3([q.x, q.y, q.z])
@@ -44,7 +54,8 @@ impl ParentFix {
}
}
}
if let Some(pr) = tr.get_mut("position") {
// Position and a camera's or light's Point of Interest are both points in the parent's space.
let map_point = |pr: &mut effectcraft_project::Property| {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
@@ -64,34 +75,19 @@ impl ParentFix {
}
}
}
};
if let Some(pr) = tr.get_mut("position") {
map_point(pr);
}
// Cameras and lights aim with Point of Interest: move it by the same parent-space
// change, so a two-node rig keeps its world frame (#1010, #1020).
if let Some(pr) = tr.get_mut("poi") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
map_point(pr);
}
if let Some((orientation, factor)) = self.pose_3d {
if let Some(pose) = self.pose_3d {
if let Some(pr) = tr.get_mut("orientation") {
pr.value = KV::Vec3(orientation);
reexpress_orientation(pose.correction, pr);
}
if let Some(pr) = tr.get_mut("scale") {
let factor = pose.factor;
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
pr.value = scaled(&pr.value);
for key in &mut pr.keys {
@@ -154,13 +150,10 @@ impl ParentFix {
}
}
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
use effectcraft_geom::{Mat4, Vec3, vec3};
if !orientation.iter().all(|v| v.is_finite()) {
return None;
}
/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a
/// mirrored parent). `None` for a non-uniform or degenerate transform.
fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> {
use effectcraft_geom::{Mat4, vec3};
let a = m.0;
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
@@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
rotation.0[row][col] = a[row][col] / factor;
}
}
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
Some((rotation, factor))
}
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
let m = correction.0;
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
}
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
let cy = r[0][0].hypot(r[1][0]);
let y = (-r[2][0]).atan2(cy);
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
let natural = near(euler);
let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]);
let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::<f64>();
if distance(flipped) < distance(natural) { flipped } else { natural }
}
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
nearest_euler(euler_zyx(world.0), reference)
}
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
const BAKE_MAX_DEPTH: u32 = 10;
const BAKE_MAX_KEYS: usize = 4096;
/// The angle (degrees) between two rotations.
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
fn bake_between(
correction: effectcraft_geom::Mat4,
authored: &effectcraft_project::Property,
(ta, ea): (Tick, [f64; 3]),
(tb, eb): (Tick, [f64; 3]),
depth: u32,
budget: usize,
out: &mut Vec<effectcraft_keyframe::Keyframe>,
) {
use effectcraft_geom::{Mat4, Vec3};
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
return;
}
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
// accepting a segment whose original easing will be replaced with a linear blend.
let quarter = (tb.0 - ta.0) / 4;
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
}) {
return;
}
let em = nearest_euler(euler_zyx(exact.0), blend);
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
if out.len() >= budget {
return;
}
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
}
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
/// value and every key. For any other correction a static value is converted directly; keys are
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
/// from the exact rotation (the segments become linear, with the original easing baked in).
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
if let Some(turn) = z_only_turn(correction) {
// No turn (an identity correction) leaves every authored value as it is.
if turn.abs() <= 1e-12 {
return;
}
let shift = |value: &KV| {
let [x, y, z] = value.as_vec3();
KV::Vec3([x, y, z + turn])
};
pr.value = shift(&pr.value);
for key in &mut pr.keys {
key.value = shift(&key.value);
}
return;
}
let value = pr.value.as_vec3();
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
if pr.keys.is_empty() {
return;
}
let authored = pr.clone();
// Each key keeps the authored change from the previous key on top of its converted value.
let mut converted: Vec<[f64; 3]> = vec![];
let mut prev: Option<([f64; 3], [f64; 3])> = None;
for key in &authored.keys {
let o = key.value.as_vec3();
let reference = match prev {
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
None => o,
};
let n = reexpress_euler(correction, o, reference);
converted.push(n);
prev = Some((o, n));
}
use effectcraft_keyframe::Interp;
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
let mut after_baked_segment = false;
for (i, key) in authored.keys.iter().enumerate() {
let mut k = key.clone();
k.value = KV::Vec3(converted[i]);
if after_baked_segment {
k.in_interp = Interp::Linear;
k.in_ease.clear();
k.auto_bezier = false;
k.continuous = false;
}
after_baked_segment = false;
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
let mut between = vec![];
if let Some(next) = segment {
// What the whole property may still add: the keys it will have already (the ones written
// and the authored ones still to come) count against the cap.
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
// the converted keys, rather than becoming a straight blend that was never checked against the original.
if between.len() >= budget {
between.clear();
// An eased segment, or one that gained keys, becomes a run of straight blends.
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
k.out_interp = Interp::Linear;
k.out_ease.clear();
k.auto_bezier = false;
k.continuous = false;
after_baked_segment = true;
}
}
keys.push(k);
keys.extend(between);
}
pr.keys = keys;
}
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim.
fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<effectcraft_geom::Mat4> {
use effectcraft_geom::vec3;
use effectcraft_render::three_d::camera::basis_rotation;
let [x, y, z] = aim;
let length = (x * x + y * y + z * z).sqrt();
if !length.is_finite() || length <= 1e-9 {
return None;
}
let dir = vec3(x / length, y / length, z / length);
let turned = rotation.apply_vec(dir);
// A view along the up axis has no roll to keep, and its basis is undefined.
if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 {
return None;
}
let up = vec3(0.0, 1.0, 0.0);
Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up))
}
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
@@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
@@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
})
};
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
let m = inv * space_3d(layer.parent);
// A one-node camera or light has no Scale to carry a mirror, and its frame (forward and
// down) would come out reversed: compensate none of it, so Position and Orientation stay
// as authored instead of half-following the parent.
let rig = layer.is_camera() || layer.is_light();
if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) {
continue;
}
if m.0.iter().flatten().all(|v| v.is_finite()) {
let pose_3d = layer.transform().and_then(|tr| {
let orientation = tr.get("orientation")?;
let scale = tr.get("scale")?;
// Animated Euler Orientation and auto-orient cannot be re-expressed by
// prepending a constant Euler value. Expression-driven values stay authored.
if !orientation.keys.is_empty()
|| orientation.expr.is_some()
|| scale.expr.is_some()
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
// Cameras and lights have no Scale; their Orientation is re-expressed alone.
let scale = tr.get("scale");
// Expression-driven values stay authored. Auto-orient replaces Orientation, except
// a two-node rig, whose roll is re-expressed against its new aim.
let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer);
if orientation.expr.is_some()
|| scale.is_some_and(|scale| scale.expr.is_some())
|| (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node)
{
return None;
}
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
let factor = pose.1;
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
(values_finite && speeds_finite).then_some(pose)
let (rotation, factor) = uniform_rotation(m)?;
let correction = if two_node {
// A mirrored parent flips the aim, which this correction does not model.
if factor <= 0.0 {
return None;
}
let (w, h) = (comp.width as f64, comp.height as f64);
let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]);
let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]);
aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])?
} else {
rotation
};
let orientation_finite = std::iter::once(&orientation.value)
.chain(orientation.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| v.is_finite()));
if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite {
return None;
}
if let Some(scale) = scale {
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite()));
if !values_finite || !speeds_finite {
return None;
}
}
Some(Pose3d { correction, factor })
});
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
}
@@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max
pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
// Locked layers keep their parent (several at once: the others are parented).
let ids = unlocked(s, cid, ids, "layer.setParent")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let parent = match p.get("parent") {
None | Some(Value::Null) => None,
@@ -323,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
s.edit("Parent", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.parent = parent;
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
f.apply(l);
+107 -18
View File
@@ -1,6 +1,6 @@
use super::{
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
layers_p, merge_p, resolve_layer, str_p,
layers_p, merge_p, resolve_layer, str_p, unlocked,
};
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
@@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
let target = v.unwrap_or(!first);
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
set_switch_value(l, &name, target);
last = target;
}
@@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
for l in comp.layers.iter_mut_where(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
@@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
s.edit("Layer Comment", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.comment = text.clone();
}
Ok(())
@@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Blending Mode", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
l.blend_mode = match mode {
Some(m) => m,
None if step > 0 => l.blend_mode.next(),
@@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left
/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps
/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers
/// at once keeps their modes.
pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
let (cid, ids) = match p.get("layers") {
Some(_) => layers_p(s, p)?,
None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?,
};
// Locked layers keep their matte (several at once: the others get it).
let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let matte = match p.get("matte") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
};
let kind = str_p(p, "kind")
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
if ids.is_empty() {
return Err(bad("layer.setTrackMatte", "no layers"));
}
if matte.is_some_and(|m| ids.contains(&m)) {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
// None: each layer keeps its own mode.
let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) {
Some(k) if k == "preserve" => None,
Some(k) => Some(match k.as_str() {
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
"luma" => MatteKind::Luma,
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
_ => MatteKind::Alpha,
})
.unwrap_or(MatteKind::Alpha);
}),
None => Some(MatteKind::Alpha),
};
s.edit("Track Matte", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = matte {
if m == lid {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
if let Some(ml) = comp.layer_mut(m) {
ml.switches.video = false;
}
if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) {
ml.switches.video = false;
}
for lid in &ids {
let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?;
let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default());
l.track_matte = matte.map(|layer| TrackMatte { layer, kind });
}
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
Ok(())
})?;
Ok(Value::Null)
}
#[cfg(test)]
mod track_matte_batch_tests {
use super::*;
use std::sync::Arc;
fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind))
}
/// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"`
/// keeps each one's mode; refused calls change nothing; one `layer` still works.
#[test]
fn several_layers_take_one_matte_and_keep_their_modes() {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap();
let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap();
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before");
let (before, undo) = (s.project.clone(), s.history.undo.len());
s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap();
assert_eq!(
[matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)],
[Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))],
"each keeps its mode (Alpha without one)"
);
assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.same_content(&before));
// Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes.
for p in [
json!({"layers": [a, y], "matte": y}),
json!({"layers": [a, 9999], "matte": y}),
json!({"layers": [], "matte": y}),
json!({"layers": [a], "matte": 9999}),
] {
let (project, revision) = (s.project.clone(), s.revision);
assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}");
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing");
}
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap();
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it");
}
/// Locked layers keep their parent and matte when they are among several (the others change),
/// and alone they are an error that changes nothing.
#[test]
fn locked_layers_keep_their_parent_and_matte() {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap();
let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap();
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap();
s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap();
assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma))));
assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was");
for (cmd, p) in [
("layer.setParent", json!({"layers": [b], "parent": p})),
("layer.setTrackMatte", json!({"layers": [b], "matte": m})),
("layer.trackMatte", json!({"layers": [b], "op": "alpha"})),
] {
let (project, revision) = (s.project.clone(), s.revision);
let err = s.execute(cmd, p).unwrap_err();
assert!(err.to_string().contains("is locked"), "{cmd}: {err}");
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing");
}
}
}
+6 -4
View File
@@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
l.in_point += d;
@@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
shift(l, d);
}
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
// Alt+]: the Out edge lands one frame after the playhead, so its frame stays the
// layer's last visible one (as in After Effects), never before the In point.
"trimOutToTime" => l.out_point = t.max(l.in_point) + fd,
_ => {
if let Some(d) = delta {
shift(l, d);
@@ -159,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
for i in 1..n {
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
comp.layers[i - 1..run_end].rotate_left(1);
comp.layers.rotate_range_left(i - 1..run_end, 1);
}
}
} else {
for i in (0..n.saturating_sub(1)).rev() {
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
comp.layers[run_start..=i + 1].rotate_right(1);
comp.layers.rotate_range_right(run_start..=i + 1, 1);
}
}
}
+34 -5
View File
@@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").unwrap_or("reset").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time();
let sizes: Vec<(LayerId, (u32, u32))> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
// A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by
// its painted bounds at the current time instead, and anchor at the bounds' centre — content
// bounds live in layer space, where a shape is centred on the origin and text sits above its
// baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the
// source size.
let ectx = effectcraft_render::EvalCtx {
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t)
};
let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight");
let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp
.layers
.iter()
.filter(|l| ids.contains(&l.id))
.map(|l| {
let (w, h) = effectcraft_render::source_size(&s.project, l);
if fitting && (w == 0 || h == 0) {
match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) {
Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]),
None => (l.id, [0.0; 2], [0.0; 2]),
}
} else {
(l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0])
}
})
.collect();
s.edit("Transform", None, |proj, _| {
for (lid, (w, h)) in &sizes {
for (lid, size, anchor) in &sizes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let (w, h) = (*w as f64, *h as f64);
let (w, h) = (size[0], size[1]);
let Some(tr) = l.transform_mut() else { continue };
let set = |tr: &mut PropGroup, m: &str, v: KV| {
if let Some(pr) = tr.get_mut(m) {
@@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
"fitHeight" => (ch / h, ch / h),
_ => (cw / w, ch / h),
};
// A tiny shape's bounds can overflow the scale; refuse rather than write infinity.
if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() {
return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0)));
}
let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d);
let (sz, az) = (z("scale", 100.0), z("anchor", 0.0));
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz]));
update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]);
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az]));
set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az]));
}
"flipH" | "flipV" => {
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);