summaryrefslogtreecommitdiff
path: root/rust/crates/animation/src/keyframes.rs
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/animation/src/keyframes.rs
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/animation/src/keyframes.rs')
-rw-r--r--rust/crates/animation/src/keyframes.rs363
1 files changed, 328 insertions, 35 deletions
diff --git a/rust/crates/animation/src/keyframes.rs b/rust/crates/animation/src/keyframes.rs
index 0ce938f9..74ecbd81 100644
--- a/rust/crates/animation/src/keyframes.rs
+++ b/rust/crates/animation/src/keyframes.rs
@@ -14,8 +14,8 @@ use crate::channel_data::{
use crate::channel_layout::{ChannelLayout, ChannelValueKind};
use crate::id::generate_uuid;
use crate::interpolation::{
- channel_value_at_time, is_scalar_channel, normalize_channel, normalize_discrete_channel,
- normalize_scalar_channel, scalar_segment_interpolation,
+ normalize_channel, normalize_discrete_channel, normalize_scalar_channel,
+ scalar_segment_interpolation,
};
use crate::types::{AnimationInterpolation, ScalarCurveKeyframePatch};
@@ -23,21 +23,15 @@ fn round_media_time(time: f64) -> MediaTime {
MediaTime::from_ticks(time.round() as i64)
}
-fn has_channel_keys(channel: Option<&AnimationChannel>) -> bool {
- channel.is_some_and(|channel| !channel.keys().is_empty())
-}
-
-trait ChannelKeys {
- fn keys(&self) -> usize;
+fn channel_key_count(channel: &AnimationChannel) -> usize {
+ match channel {
+ AnimationChannel::Scalar(scalar) => scalar.keys.len(),
+ AnimationChannel::Discrete(discrete) => discrete.keys.len(),
+ }
}
-impl ChannelKeys for AnimationChannel {
- fn keys(&self) -> usize {
- match self {
- AnimationChannel::Scalar(scalar) => scalar.keys.len(),
- AnimationChannel::Discrete(discrete) => discrete.keys.len(),
- }
- }
+fn has_channel_keys(channel: Option<&AnimationChannel>) -> bool {
+ channel.is_some_and(|channel| channel_key_count(channel) > 0)
}
fn has_channel_data(data: Option<&ChannelData>) -> bool {
@@ -296,7 +290,7 @@ pub fn get_channel<'a>(
animations: Option<&'a ElementAnimations>,
property_path: &str,
) -> Option<&'a AnimationChannel> {
- let data = animations?.get(property_path);
+ let data = animations?.get(property_path)?;
match data {
ChannelData::Single(channel) => Some(channel),
ChannelData::Composite(_) => get_channels_from_data(Some(data)).into_iter().next(),
@@ -313,13 +307,18 @@ pub fn upsert_path_keyframe(
channel_layout: &ChannelLayout,
coerce_value: impl Fn(&ParamValue) -> Option<ParamValue>,
) -> Option<ElementAnimations> {
- let coerced_value = coerce_value(value)?;
+ let coerced_value = match coerce_value(value) {
+ Some(coerced_value) => coerced_value,
+ None => return animations.cloned(),
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let current_data = animations.and_then(|a| a.get(property_path)).cloned();
let primary_channel = get_primary_channel_from_data(current_data.as_ref(), channel_layout);
let (target_id, target_time) = get_target_key_metadata(primary_channel, time, keyframe_id);
- let component_values = channel_layout.decompose(&coerced_value)?;
+ let Some(component_values) = channel_layout.decompose(&coerced_value) else {
+ return animations.cloned();
+ };
let mut next_data = current_data.clone();
for component in channel_layout.components() {
@@ -731,8 +730,8 @@ fn split_discrete_channel_at_time(
if include_split_boundary {
let has_boundary_on_left = left_keys.iter().any(|key| key.time == split_time);
let has_boundary_on_right = right_keys.iter().any(|key| key.time == MediaTime::ZERO);
- let boundary_value = discrete_value_at_time(
- &normalized,
+ let boundary_value = crate::interpolation::discrete_channel_value_at_time(
+ Some(&normalized),
split_time,
normalized.keys[0].value.clone(),
);
@@ -773,17 +772,6 @@ fn split_discrete_channel_at_time(
}
}
-fn discrete_value_at_time(
- channel: &DiscreteChannel,
- time: MediaTime,
- fallback_value: DiscreteValue,
-) -> DiscreteValue {
- let ParamValue::Bool(_) = ParamValue::Bool(false) else {
- unreachable!()
- };
- crate::interpolation::discrete_channel_value_at_time(Some(channel), time, fallback_value)
-}
-
fn build_split_scalar_result(
left_keys: Vec<ScalarAnimationKey>,
right_keys: Vec<ScalarAnimationKey>,
@@ -1087,11 +1075,15 @@ pub fn remove_element_keyframe(
property_path: &str,
keyframe_id: &str,
) -> Option<ElementAnimations> {
- let data = animations.and_then(|a| a.get(property_path))?;
+ let Some(data) = animations.and_then(|a| a.get(property_path)) else {
+ return animations.cloned();
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let next_data = match data {
- ChannelData::Single(channel) => remove_keyframe(Some(channel), keyframe_id),
+ ChannelData::Single(channel) => {
+ remove_keyframe(Some(channel), keyframe_id).map(ChannelData::Single)
+ }
ChannelData::Composite(components) => {
let mut next_data = Some(data.clone());
for (component_key, channel) in components {
@@ -1121,11 +1113,15 @@ pub fn retime_element_keyframe(
keyframe_id: &str,
time: MediaTime,
) -> Option<ElementAnimations> {
- let data = animations.and_then(|a| a.get(property_path))?;
+ let Some(data) = animations.and_then(|a| a.get(property_path)) else {
+ return animations.cloned();
+ };
let mut next_animations = animations.cloned().unwrap_or_default();
let next_data = match data {
- ChannelData::Single(channel) => retime_keyframe(Some(channel), keyframe_id, time),
+ ChannelData::Single(channel) => {
+ retime_keyframe(Some(channel), keyframe_id, time).map(ChannelData::Single)
+ }
ChannelData::Composite(components) => {
let mut next_data = Some(data.clone());
for (component_key, channel) in components {
@@ -1148,3 +1144,300 @@ pub fn retime_element_keyframe(
}
to_animation(next_animations)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::channel_layout::number_channel_layout;
+ use scene::CurveHandle;
+
+ fn at(time: i64) -> MediaTime {
+ MediaTime::from_ticks(time)
+ }
+
+ fn scalar_key(id: &str, time: i64, value: f64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: at(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Linear,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn bezier_ease_key(id: &str, time: i64, value: f64, right: bool) -> ScalarAnimationKey {
+ let mut key = scalar_key(id, time, value);
+ key.segment_to_next = ScalarSegmentType::Bezier;
+ if right {
+ key.right_handle = Some(CurveHandle { dt: at(50), dv: 0.0 });
+ } else {
+ key.left_handle = Some(CurveHandle { dt: at(-50), dv: 0.0 });
+ }
+ key
+ }
+
+ fn scalar_channel(keys: Vec<ScalarAnimationKey>) -> ScalarChannel {
+ ScalarChannel { keys, extrapolation: None }
+ }
+
+ fn animations_with(path: &str, data: ChannelData) -> ElementAnimations {
+ let mut animations = ElementAnimations::new();
+ animations.insert(path.to_string(), data);
+ animations
+ }
+
+ #[test]
+ fn upsert_scalar_key_inserts_sorted() {
+ let channel = upsert_scalar_channel_key(None, at(100), 1.0, None, None, Some("b"));
+ let channel = upsert_scalar_channel_key(Some(&channel), at(0), 0.0, None, None, Some("a"));
+ assert_eq!(channel.keys.len(), 2);
+ assert_eq!(channel.keys[0].id, "a");
+ assert_eq!(channel.keys[1].id, "b");
+ }
+
+ #[test]
+ fn upsert_scalar_key_replaces_value_at_same_time_keeping_id() {
+ let channel = upsert_scalar_channel_key(None, at(50), 1.0, None, None, Some("a"));
+ let channel = upsert_scalar_channel_key(Some(&channel), at(50), 2.0, None, None, None);
+ assert_eq!(channel.keys.len(), 1);
+ assert_eq!(channel.keys[0].id, "a");
+ assert_eq!(channel.keys[0].value, 2.0);
+ }
+
+ #[test]
+ fn upsert_scalar_key_by_id_moves_time_and_preserves_segment() {
+ let channel = upsert_scalar_channel_key(
+ None, at(0), 0.0, Some(AnimationInterpolation::Bezier), None, Some("a"),
+ );
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ let channel = upsert_scalar_channel_key(Some(&channel), at(80), 5.0, None, None, Some("a"));
+ assert_eq!(channel.keys[0].time, at(80));
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ }
+
+ #[test]
+ fn new_key_defaults_to_component_interpolation() {
+ let channel = upsert_scalar_channel_key(
+ None, at(0), 0.0, None, Some(AnimationInterpolation::Hold), Some("a"),
+ );
+ assert_eq!(channel.keys[0].segment_to_next, ScalarSegmentType::Step);
+ }
+
+ #[test]
+ fn remove_keyframe_returns_none_when_empty() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![scalar_key("a", 0, 1.0)]));
+ assert!(remove_keyframe(Some(&channel), "a").is_none());
+ let kept = remove_keyframe(Some(&channel), "missing").unwrap();
+ assert_eq!(channel_key_count(&kept), 1);
+ }
+
+ #[test]
+ fn retime_keyframe_resorts_channel() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 1.0),
+ ]));
+ let retimed = retime_keyframe(Some(&channel), "a", at(200)).unwrap();
+ let AnimationChannel::Scalar(scalar) = retimed else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].id, "b");
+ assert_eq!(scalar.keys[1].id, "a");
+ assert_eq!(scalar.keys[1].time, at(200));
+ }
+
+ #[test]
+ fn upsert_path_keyframe_creates_leaf_channel() {
+ let result = upsert_path_keyframe(
+ None, "opacity", at(0), &ParamValue::Number(0.5), None, Some("k1"),
+ &number_channel_layout(), |value| Some(value.clone()),
+ )
+ .unwrap();
+ let channel = get_channel(Some(&result), "opacity").unwrap();
+ assert_eq!(channel_key_count(channel), 1);
+ }
+
+ #[test]
+ fn upsert_path_keyframe_rejects_uncoercible_values() {
+ let result = upsert_path_keyframe(
+ None, "opacity", at(0), &ParamValue::Number(f64::NAN), None, None,
+ &number_channel_layout(), |_| None,
+ );
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn upsert_path_keyframe_decomposes_color_into_components() {
+ let result = upsert_path_keyframe(
+ None, "color", at(0), &ParamValue::String("#ff0000".to_string()), None, Some("k1"),
+ &crate::channel_layout::color_channel_layout(), |value| Some(value.clone()),
+ )
+ .unwrap();
+ let Some(ChannelData::Composite(components)) = result.get("color") else {
+ panic!("expected composite data")
+ };
+ assert_eq!(components.len(), 4);
+ for component_key in ["r", "g", "b", "a"] {
+ assert_eq!(channel_key_count(components.get(component_key).unwrap()), 1);
+ }
+ }
+
+ #[test]
+ fn split_linear_channel_inserts_boundary_keys() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+
+ let left_channel = get_channel(left.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(left_scalar) = left_channel else { panic!("expected scalar") };
+ assert_eq!(left_scalar.keys.len(), 2);
+ assert_eq!(left_scalar.keys[1].time, at(50));
+ assert_eq!(left_scalar.keys[1].value, 5.0);
+
+ let right_channel = get_channel(right.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(right_scalar) = right_channel else { panic!("expected scalar") };
+ assert_eq!(right_scalar.keys.len(), 2);
+ assert_eq!(right_scalar.keys[0].time, at(0));
+ assert_eq!(right_scalar.keys[0].value, 5.0);
+ assert_eq!(right_scalar.keys[1].time, at(50));
+ assert_eq!(right_scalar.keys[1].value, 10.0);
+ }
+
+ #[test]
+ fn split_bezier_channel_preserves_curve_value_at_boundary() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ bezier_ease_key("a", 0, 0.0, true),
+ bezier_ease_key("b", 100, 100.0, false),
+ ]));
+ let ParamValue::Number(sampled) = crate::interpolation::channel_value_at_time(
+ Some(&channel), at(50), &ParamValue::Number(0.0),
+ ) else {
+ panic!("expected number")
+ };
+
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+ let left_channel = get_channel(left.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(left_scalar) = left_channel else { panic!("expected scalar") };
+ let boundary = left_scalar.keys.last().unwrap();
+ assert_eq!(boundary.time, at(50));
+ assert!((boundary.value - sampled).abs() < 1e-9);
+
+ let right_channel = get_channel(right.as_ref(), "opacity").unwrap();
+ let AnimationChannel::Scalar(right_scalar) = right_channel else { panic!("expected scalar") };
+ assert_eq!(right_scalar.keys[0].time, at(0));
+ assert!((right_scalar.keys[0].value - sampled).abs() < 1e-9);
+ }
+
+ #[test]
+ fn split_without_boundary_excludes_split_point_key() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), false);
+ assert_eq!(channel_key_count(get_channel(left.as_ref(), "opacity").unwrap()), 1);
+ assert_eq!(channel_key_count(get_channel(right.as_ref(), "opacity").unwrap()), 1);
+ }
+
+ #[test]
+ fn split_discrete_channel_carries_current_value() {
+ let channel = AnimationChannel::Discrete(DiscreteChannel {
+ keys: vec![
+ DiscreteAnimationKey { id: "a".to_string(), time: at(0), value: DiscreteValue::String("x".to_string()) },
+ DiscreteAnimationKey { id: "b".to_string(), time: at(100), value: DiscreteValue::String("y".to_string()) },
+ ],
+ });
+ let animations = animations_with("params.label", ChannelData::Single(channel));
+ let (left, right) = split_animations_at_time_with_options(Some(&animations), at(50), true);
+ let left_channel = get_channel(left.as_ref(), "params.label").unwrap();
+ let AnimationChannel::Discrete(left_discrete) = left_channel else { panic!("expected discrete") };
+ assert_eq!(left_discrete.keys.len(), 2);
+ assert_eq!(left_discrete.keys[1].value, DiscreteValue::String("x".to_string()));
+ let right_channel = get_channel(right.as_ref(), "params.label").unwrap();
+ let AnimationChannel::Discrete(right_discrete) = right_channel else { panic!("expected discrete") };
+ assert_eq!(right_discrete.keys[0].value, DiscreteValue::String("x".to_string()));
+ }
+
+ #[test]
+ fn clamp_animations_drops_beyond_duration_and_keeps_boundary() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let clamped = clamp_animations_to_duration(Some(&animations), at(40)).unwrap();
+ let channel = get_channel(Some(&clamped), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys.len(), 2);
+ assert_eq!(scalar.keys[1].time, at(40));
+ assert_eq!(scalar.keys[1].value, 4.0);
+ assert!(clamp_animations_to_duration(Some(&animations), at(0)).is_none());
+ assert!(clamp_animations_to_duration(None, at(10)).is_none());
+ }
+
+ #[test]
+ fn clone_animations_regenerates_ids() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let cloned = clone_animations(Some(&animations), true).unwrap();
+ let channel = get_channel(Some(&cloned), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys.len(), 2);
+ assert_ne!(scalar.keys[0].id, "a");
+ assert_ne!(scalar.keys[1].id, "b");
+ assert_eq!(scalar.keys[0].time, at(0));
+
+ let kept = clone_animations(Some(&animations), false).unwrap();
+ let channel = get_channel(Some(&kept), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].id, "a");
+ assert!(clone_animations(None, true).is_none());
+ }
+
+ #[test]
+ fn update_scalar_keyframe_curve_patches_handles() {
+ let channel = AnimationChannel::Scalar(scalar_channel(vec![
+ scalar_key("a", 0, 0.0),
+ scalar_key("b", 100, 10.0),
+ ]));
+ let animations = animations_with("opacity", ChannelData::Single(channel));
+ let patch = ScalarCurveKeyframePatch {
+ left_handle: None,
+ right_handle: Some(Some(CurveHandle { dt: at(10), dv: 2.0 })),
+ segment_to_next: Some(ScalarSegmentType::Bezier),
+ tangent_mode: Some(TangentMode::Broken),
+ };
+ let updated = update_scalar_keyframe_curve(Some(&animations), "opacity", "value", "a", &patch).unwrap();
+ let channel = get_channel(Some(&updated), "opacity").unwrap();
+ let AnimationChannel::Scalar(scalar) = channel else { panic!("expected scalar") };
+ assert_eq!(scalar.keys[0].segment_to_next, ScalarSegmentType::Bezier);
+ assert_eq!(scalar.keys[0].tangent_mode, TangentMode::Broken);
+ assert_eq!(scalar.keys[0].right_handle.unwrap().dv, 2.0);
+ }
+
+ #[test]
+ fn remove_element_keyframe_across_composite_components() {
+ let mut components = BTreeMap::new();
+ components.insert("r".to_string(), AnimationChannel::Scalar(scalar_channel(vec![scalar_key("k", 0, 1.0)])));
+ components.insert("g".to_string(), AnimationChannel::Scalar(scalar_channel(vec![scalar_key("k", 0, 0.0)])));
+ let animations = animations_with("color", ChannelData::Composite(components));
+ assert!(remove_element_keyframe(Some(&animations), "color", "k").is_none());
+ }
+
+ #[test]
+ fn empty_channels_are_filtered_by_to_animation() {
+ let animations = animations_with(
+ "opacity",
+ ChannelData::Single(AnimationChannel::Scalar(scalar_channel(Vec::new()))),
+ );
+ assert!(to_animation(animations).is_none());
+ }
+}