mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Timeline: UU reveals modified options, layer styles and fixes false positives
The modified reveal compares each layer with a fresh layer of the same kind: Audio, Material, Geometry, Camera and Light Options and a text layer's Path and More Options now count when changed. Layer styles count in full, and overridden Essential Properties are revealed. Cameras and lights compare their Transform with their own defaults, and separated X/Y Position with the comp centre, so untouched layers reveal nothing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c81a9f1061
commit
ba2770a258
2 files changed
+135
-1
No files matched your search
@@ -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})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user