Merge pull request #867 from pravdomil/claude/uu-modified-defaults

Timeline: UU reveals modified options and layer styles, without false positives
This commit is contained in:
Brandon Thomas committed 2026-10-12 04:20:36 -04:00
commit cafd18eef4
2 files changed
+135 -1

No files matched your search

+71 -1
View File
@@ -5,7 +5,8 @@
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
// Separate Dimensions: X and Y Position default to the comp centre.
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
@@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
}
}
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
let mut next = 0;
let mut ids = Ids(&mut next);
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
let mut g = match top.match_id.as_str() {
"transform" if !camera_or_light => return None,
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
"audio" => build::audio(&mut ids),
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
_ => return None,
};
match top.match_id.as_str() {
// Source Text and the animators are the layer's content, not options with a default.
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
// A model's scale and clip come from its file.
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
_ => {}
}
Some(g)
}
/// Static properties of `g` whose value differs from the same property of `default` (matched by
/// match id; properties `default` lacks have no known default and are left out).
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
for c in &g.children {
match c {
Node::Prop(pr) => {
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
out.push(pr.uid);
}
}
Node::Group(sg) => {
if let Some(d) = default.sub(&sg.match_id) {
changed_from(sg, d, out);
}
}
}
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
@@ -324,10 +372,15 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
out.push(json!({"layer": lid.0, "prop": pr.uid}));
}
}
// Modified: a fresh layer of the same kind gives the defaults to compare against.
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
// A camera's or light's Transform is compared with a fresh one's below.
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
@@ -347,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
found.push(g.uid);
}
}
if let Some(fresh) = &fresh {
if let Some(default) = default_group(top, l, fresh) {
changed_from(top, &default, &mut found);
}
// Every layer style counts in full, like an effect; so does each overridden
// Essential Property.
if top.match_id == effectcraft_project::styles::GROUP {
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
}
if let GroupKind::Essential { overridden } = &top.kind {
for u in overridden {
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
found.push(*u);
}
}
}
}
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
}
}
+64
View File
@@ -731,6 +731,70 @@ fn reveal_shortcuts_take_in_time_remap() {
}
}
/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text
/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre.
#[test]
fn modified_reveal_is_empty_for_untouched_layers() {
let (mut s, solid) = setup();
let mut layers = vec![("solid", solid)];
for (cmd, params) in [
("layer.newText", json!({"text": "Hi"})),
("layer.newShape", json!({})),
("layer.newNull", json!({})),
("layer.newCamera", json!({})),
("layer.newLight", json!({})),
("layer.newLight", json!({"type": "ambient"})),
] {
let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}"));
layers.push((cmd, r["layer"].as_u64().unwrap()));
}
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
for (what, l) in layers {
s.execute("layer.select", json!({"layers": [l]})).unwrap();
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
let paths: Vec<String> = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect();
assert!(paths.is_empty(), "{what}: {paths:?}");
}
}
/// Match-id path of property or group `uid` of `layer`.
fn path_of(s: &Session, layer: u64, uid: u64) -> String {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
let mut out = String::new();
for n in l.props.node_chain(uid).unwrap_or_default() {
out.push('/');
out.push_str(n.match_id());
}
out
}
/// UU takes in static changes outside Transform: Material, Camera and Light Options, a
/// separated position, and layer styles in full.
#[test]
fn modified_reveal_takes_in_options_and_layer_styles() {
let (mut s, solid) = setup();
let modified = |s: &mut Session, l: u64| -> Vec<String> {
s.execute("layer.select", json!({"layers": [l]})).unwrap();
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect()
};
s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap();
assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]);
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap();
assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]);
s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap();
assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid));
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap();
assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]);
let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap();
assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]);
}
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
/// the end key at the source's end, not at the extended out point (#290).
#[test]