Merge pull request #522 from sanyamsingh001/fix/property-key-time-bases

This commit is contained in:
Claude
2026-10-10 12:49:17 +00:00
8 changed files with 661 additions and 37 deletions
+23 -11
View File
@@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::prop::prop_ref;
use super::prop::{KeyTimeBase, key_time, key_timing, prop_ref};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
@@ -206,11 +206,13 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "keys.set")?;
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?);
let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64);
let base = KeyTimeBase::from_params(p, "keys.set")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
let value = p.get("value").cloned();
let fr = s.project.comp(cid).map(|c| c.frame_rate);
s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let nt = s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?;
@@ -221,10 +223,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut nt = old_t;
if let Some(n) = new_t {
nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n);
nt = n;
// Keys can't pass their neighbours or share a time.
let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX };
let lo = if i > 0 { pr.keys[i - 1].time.0.saturating_add(1) } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0.saturating_sub(1) } else { i64::MAX };
nt = Tick(nt.0.clamp(lo, hi));
pr.keys[i].time = nt;
}
@@ -233,8 +235,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) {
k.time = nt;
}
Ok(json!({"time": nt.seconds()}))
})
Ok(nt)
})?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
Ok(key_timing(layer, nt))
}
fn nearest_key(keys: &[Keyframe], t: Tick) -> Option<usize> {
@@ -449,7 +453,15 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"frames": -10})
)),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
cmd!(
"keys.set",
"Edit Keyframe",
[],
None,
"{layer?, path|prop, time (layer s by default), newTime? (same base), timeBase?: layer|comp, value?, merge?}",
has_comp,
set_key
),
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
+164 -21
View File
@@ -2,7 +2,7 @@
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
use effectcraft_time::Tick;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
@@ -29,7 +29,7 @@ fn with_prop<T>(
uid: Uid,
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
) -> Result<T> {
let t = s.time();
let t = s.time_of(cid);
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
@@ -50,12 +50,106 @@ fn with_prop<T>(
})
}
/// An explicit key time (layer seconds) moved onto the nearest comp frame.
fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option<Tick> {
let t = Tick::from_seconds_f64(f_p(p, "time")?);
let comp = s.project.comp(cid)?;
let l = comp.layer(lid)?;
Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t))))
/// Explicit command key times default to layer time; readers and the CTI use comp time.
#[derive(Clone, Copy)]
pub(crate) enum KeyTimeBase {
Layer,
Comp,
}
impl KeyTimeBase {
pub(crate) fn from_params(p: &Value, cmd: &str) -> Result<Self> {
match p.get("timeBase") {
None => Ok(Self::Layer),
Some(Value::String(b)) if b == "layer" => Ok(Self::Layer),
Some(Value::String(b)) if b == "comp" => Ok(Self::Comp),
Some(_) => Err(bad(cmd, "`timeBase` must be layer or comp")),
}
}
}
/// Convert a supplied time to stored layer ticks, snapping writes in the comp's frame grid.
pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &str, base: KeyTimeBase, cmd: &str, snap: bool) -> Result<Option<Tick>> {
let Some(value) = p.get(param) else { return Ok(None) };
let seconds = value
.as_f64()
.filter(|t| t.is_finite() && *t >= Tick::MIN.seconds() && *t <= Tick::MAX.seconds())
.ok_or_else(|| bad(cmd, format!("`{param}` must be finite seconds within the supported time range")))?;
if !layer.stretch.is_finite() || layer.stretch == 0.0 {
return Err(bad(cmd, "the layer has an invalid time stretch"));
}
let t = Tick::from_seconds_f64(seconds);
let conversion_error = || bad(cmd, format!("`{param}` converts outside the supported time range"));
let in_range = |ticks: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&ticks);
// Validate before Layer's saturating conversion: otherwise a huge stretch can silently
// clamp an intended key to a different comp time while still producing a valid Tick.
let scaled_tick = |ticks: f64| -> Result<Tick> {
if !ticks.is_finite() || ticks < Tick::MIN.0 as f64 || ticks > Tick::MAX.0 as f64 {
return Err(conversion_error());
}
let ticks = ticks as i64;
if !in_range(ticks) {
return Err(conversion_error());
}
Ok(Tick(ticks))
};
let normal = (layer.stretch - 100.0).abs() < 1e-9;
let ct = match base {
KeyTimeBase::Layer => {
let offset = if normal { t } else { scaled_tick(t.0 as f64 * layer.stretch / 100.0)? };
Tick(layer.start_time.0.checked_add(offset.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?)
}
KeyTimeBase::Comp => t,
};
let ct = if snap { frame_rate.snap_nearest(ct) } else { ct };
if !in_range(ct.0) {
return Err(conversion_error());
}
let offset = ct.0.checked_sub(layer.start_time.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?;
let lt = if normal { Tick(offset) } else { scaled_tick(offset as f64 * 100.0 / layer.stretch)? };
// Unsnapped legacy layer times remain exact, rather than making a float round-trip.
Ok(Some(if !snap && matches!(base, KeyTimeBase::Layer) { t } else { lt }))
}
pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value {
let ct = layer.comp_time(t);
let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()});
if !layer.is_active_at(ct) {
out["warning"] = json!(format!(
"Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).",
ct.seconds(),
layer.in_point.seconds(),
layer.out_point.seconds()
));
}
out
}
/// Omitted bases keep the original JSON reply; explicit bases opt in to timing metadata.
fn key_reply(p: &Value, mut timing: Value, field: &str, result: Value) -> Value {
if p.get("timeBase").is_some() {
timing[field] = result;
timing
} else {
result
}
}
fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value, cmd: &str) -> Result<Option<Tick>> {
let base = KeyTimeBase::from_params(p, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
key_time(layer, comp.frame_rate, p, "time", base, cmd, true)
}
fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Value) -> Value {
if p.get("timeBase").is_some() {
let mut timing = key_timing(layer, time);
timing["animated"] = json!(pr.is_animated());
key_reply(p, timing, "value", value)
} else {
value
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -87,7 +181,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!(out));
}
let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone();
let at = snapped_key_time(s, cid, lid, p);
let at = snapped_key_time(s, cid, lid, p, "prop.set")?;
let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| {
let cur = pr.value_at(lt);
let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?;
@@ -100,7 +194,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
pr.set_value_at(at.unwrap_or(lt), nv.clone());
Ok(nv.to_json())
})?;
Ok(out)
if p.get("timeBase").is_none() {
return Ok(out);
}
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad("prop.set", "property vanished"))?;
Ok(property_reply(p, layer, pr, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid))), out))
}
/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression.
@@ -108,9 +207,18 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.get")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let keys: Vec<Value> =
pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect();
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let keys: Vec<Value> = pr
.keys
.iter()
.map(|k| {
let mut info = key_timing(l, k.time);
info["value"] = k.value.to_json();
info["in"] = json!(k.in_interp.label());
info["out"] = json!(k.out_interp.label());
info
})
.collect();
let raw = pr.value_at(l.layer_time(t));
let mut out = json!({
"layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(),
@@ -156,9 +264,9 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?;
let at = snapped_key_time(s, cid, lid, p);
let at = snapped_key_time(s, cid, lid, p, "prop.addKey")?;
let value = p.get("value").cloned();
with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
let count = with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
let t = at.unwrap_or(lt);
let cur = pr.value_at(t);
let v = match &value {
@@ -171,7 +279,12 @@ fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
}
set_key(&mut pr.keys, k);
Ok(json!(pr.keys.len()))
})
})?;
if p.get("timeBase").is_none() {
return Ok(count);
}
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
Ok(key_reply(p, key_timing(layer, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid)))), "count", count))
}
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
@@ -366,6 +479,7 @@ fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
// ---------------------------------------------------------------- keyframes
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
let base = KeyTimeBase::from_params(p, "keys.select")?;
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut sel = vec![];
if let Some(Value::Array(keys)) = p.get("keys") {
@@ -382,7 +496,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))),
}
.ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?;
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
let t = key_time(ly, comp.frame_rate, k, "time", base, "keys.select", false)?.ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
// Snap to the stored key time.
let kt = pr
.keys
@@ -432,7 +546,28 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(json!(s.state.selected_keys.len()))
if p.get("timeBase").is_none() {
return Ok(json!(s.state.selected_keys.len()));
}
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let keys: Vec<Value> = s
.state
.selected_keys
.iter()
.filter_map(|k| {
let layer = comp.layer(k.layer)?;
let mut timing = key_timing(layer, k.time);
timing["layer"] = json!(k.layer.0);
timing["prop"] = json!(k.prop);
Some(timing)
})
.collect();
let warnings: Vec<&str> = keys.iter().filter_map(|k| k.get("warning").and_then(Value::as_str)).collect();
let mut out = json!({"count": s.state.selected_keys.len(), "keys": keys});
if !warnings.is_empty() {
out["warning"] = json!(warnings.join(" "));
}
Ok(out)
}
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
@@ -800,9 +935,17 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
cmd!(
"prop.set",
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}",
has_layers,
set
),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
cmd!(
"keys.toggleTransform",
@@ -838,7 +981,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Keyframes",
[],
None,
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
"{keys: [{layer, prop: uid | path (or `path`), time (layer s by default)}], timeBase?: layer|comp, add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
has_comp,
select_keys
),
+3
View File
@@ -1167,3 +1167,6 @@ mod tests_paint;
mod tests_panels;
#[cfg(test)]
mod tests_roto;
#[cfg(test)]
mod tests_key_time;
+149
View File
@@ -0,0 +1,149 @@
//! Agent keyframe times on offset, stretched and reversed layers (#257).
use effectcraft_project::LayerId;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::{Session, tests_timeline::prop};
fn offset_layer(start: f64, stretch: f64) -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Offset", "duration": 20, "frameRate": 30})).unwrap();
let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layer": layer, "start": start, "in": 10.5, "out": 20})).unwrap();
let cid = s.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap().stretch = stretch;
(s, layer)
}
fn assert_seconds(value: &Value, seconds: f64) {
assert!((value.as_f64().unwrap() - seconds).abs() < 1e-8, "{value} != {seconds}");
}
#[test]
fn comp_key_times_on_an_offset_layer_read_at_the_intended_time_and_undo() {
let (mut s, layer) = offset_layer(10.5, 100.0);
for (time, value) in [(15.3, 0), (16.0, 100)] {
let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": value})).unwrap();
assert_seconds(&reply["compTime"], time);
assert_seconds(&reply["layerTime"], time - 10.5);
assert!(reply.get("warning").is_none());
}
let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity", "time": 15.65})).unwrap();
assert_seconds(&read["value"], 50.0);
assert_seconds(&read["keys"][0]["compTime"], 15.3);
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.5, "timeBase": "comp", "value": 45})).unwrap();
assert_seconds(&reply["compTime"], 15.5);
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 2);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3);
}
#[test]
fn default_layer_times_are_preserved_and_outside_keys_warn() {
let (mut s, layer) = offset_layer(10.5, 100.0);
let legacy = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 0})).unwrap();
assert_eq!(legacy, json!(1), "ordinary legacy replies stay scalar");
let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "value": 100})).unwrap();
assert_eq!(outside, json!(2), "legacy replies keep their raw count outside the active range");
let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity"})).unwrap();
assert_seconds(&read["keys"][1]["time"], 15.3);
assert_seconds(&read["keys"][1]["compTime"], 25.8);
assert!(read["keys"][1].get("warning").is_some());
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 25.8}], "timeBase": "comp"})).unwrap();
assert_seconds(&selected["keys"][0]["compTime"], 25.8);
assert!(selected.get("warning").is_some());
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap();
assert_seconds(&moved["compTime"], 20.0);
assert!(moved.get("warning").is_some(), "out point is exclusive");
}
#[test]
fn stretched_and_reversed_keys_use_the_comp_frame_grid_for_both_bases() {
for (start, stretch) in [(10.5, 200.0), (18.0, -200.0)] {
let (mut s, layer) = offset_layer(start, stretch);
for time in [15.3, 16.0] {
let layer_time = (time - start) * 100.0 / stretch;
let comp_reply =
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": 20})).unwrap();
assert_seconds(&comp_reply["layerTime"], layer_time);
let layer_reply =
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": layer_time, "timeBase": "layer", "value": 20})).unwrap();
assert_seconds(&layer_reply["compTime"], time);
}
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 15.3}], "timeBase": "comp"})).unwrap();
assert_seconds(&selected["keys"][0]["compTime"], 15.3);
let moved = s
.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 15.41, "timeBase": "comp", "value": 60}))
.unwrap();
assert_seconds(&moved["compTime"], 15.4);
let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
assert_eq!(ly.comp_time(s.state.selected_keys[0].time), Tick::from_seconds_f64(15.4));
let layer_reply = s.execute("keys.set", json!({"layer": layer, "path": "transform/rotation", "time": (15.3 - start) * 100.0 / stretch, "newTime": (15.41 - start) * 100.0 / stretch, "timeBase": "layer"})).unwrap();
assert_seconds(&layer_reply["compTime"], 15.4);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
let restored = prop(&s, layer, "transform/opacity");
assert!(restored.keys.iter().any(|k| ly.comp_time(k.time) == Tick::from_seconds_f64(15.3)));
}
}
#[test]
fn invalid_key_time_arguments_leave_properties_and_history_unchanged() {
let (mut s, layer) = offset_layer(10.5, 100.0);
let before = prop(&s, layer, "transform/opacity");
let history_len = s.history.undo.len();
for cmd in ["prop.addKey", "prop.set", "keys.set"] {
for extra in [json!({"time": "oops"}), json!({"time": 1e300}), json!({"timeBase": "seconds", "time": 0})] {
let mut p = json!({"layer": layer, "path": "transform/opacity", "value": 10});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(s.execute(cmd, p).is_err(), "{cmd}: {extra}");
assert_eq!(prop(&s, layer, "transform/opacity"), before);
assert_eq!(s.history.undo.len(), history_len);
}
}
assert!(s.execute("keys.select", json!({"keys": [], "timeBase": null})).is_err());
}
#[test]
fn omitted_key_times_and_reads_use_the_target_comps_cti() {
let (mut s, layer) = offset_layer(10.5, 100.0);
let target = s.active_comp_id().unwrap().0;
s.execute("time.set", json!({"time": 15.3})).unwrap();
s.execute("comp.new", json!({"name": "Other", "duration": 20})).unwrap();
s.execute("time.set", json!({"time": 1})).unwrap();
let reply = s.execute("prop.addKey", json!({"comp": target, "layer": layer, "path": "transform/opacity", "timeBase": "comp", "value": 25})).unwrap();
assert_seconds(&reply["compTime"], 15.3);
let read = s.execute("prop.get", json!({"comp": target, "layer": layer, "path": "transform/opacity"})).unwrap();
assert_seconds(&read["time"], 15.3);
assert_seconds(&read["value"], 25.0);
}
#[test]
fn excessive_stretch_or_start_conversions_fail_without_clamping_keys() {
for (start, stretch, base, time) in [(10.5, 1e300, "layer", 1.0), (10.5, 1e-300, "comp", 15.3), (Tick::MAX.seconds(), 100.0, "layer", 1.0)] {
let (mut s, layer) = offset_layer(10.5, 100.0);
let cid = s.active_comp_id().unwrap();
let ly = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap();
ly.start_time = Tick::from_seconds_f64(start);
ly.stretch = stretch;
let before = s.history.undo.len();
let result = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": base, "value": 10}));
assert!(result.is_err(), "start {start}, stretch {stretch}: {result:?}");
assert_eq!(s.history.undo.len(), before);
assert!(prop(&s, layer, "transform/opacity").keys.is_empty());
}
}
#[test]
fn explicit_base_on_static_property_reports_timing_without_enabling_animation() {
let (mut s, layer) = offset_layer(10.5, 100.0);
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 25})).unwrap();
assert_seconds(&reply["compTime"], 15.3);
assert_eq!(reply["animated"], false);
assert_eq!(reply["value"], 25.0);
assert!(prop(&s, layer, "transform/opacity").keys.is_empty());
}