summaryrefslogtreecommitdiff
path: root/rust/crates/animation/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/animation/src')
-rw-r--r--rust/crates/animation/src/bezier.rs156
-rw-r--r--rust/crates/animation/src/channel_data.rs92
-rw-r--r--rust/crates/animation/src/channel_layout.rs124
-rw-r--r--rust/crates/animation/src/color.rs137
-rw-r--r--rust/crates/animation/src/id.rs61
-rw-r--r--rust/crates/animation/src/interpolation.rs467
-rw-r--r--rust/crates/animation/src/keyframes.rs1150
-rw-r--r--rust/crates/animation/src/lib.rs1
-rw-r--r--rust/crates/animation/src/types.rs45
9 files changed, 2233 insertions, 0 deletions
diff --git a/rust/crates/animation/src/bezier.rs b/rust/crates/animation/src/bezier.rs
new file mode 100644
index 00000000..6bec252f
--- /dev/null
+++ b/rust/crates/animation/src/bezier.rs
@@ -0,0 +1,156 @@
+use scene::{CurveHandle, ScalarAnimationKey};
+use time::MediaTime;
+
+pub const BEZIER_SOLVE_ITERATIONS: usize = 20;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BezierHandle {
+ pub dt: f64,
+ pub dv: f64,
+}
+
+impl From<CurveHandle> for BezierHandle {
+ fn from(handle: CurveHandle) -> Self {
+ Self {
+ dt: handle.dt.as_ticks() as f64,
+ dv: handle.dv,
+ }
+ }
+}
+
+pub fn bezier_point(progress: f64, p0: f64, p1: f64, p2: f64, p3: f64) -> f64 {
+ let mt = 1.0 - progress;
+ mt * mt * mt * p0
+ + 3.0 * mt * mt * progress * p1
+ + 3.0 * mt * progress * progress * p2
+ + progress * progress * progress * p3
+}
+
+pub fn default_right_handle(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> BezierHandle {
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ let value_delta = right_key.value - left_key.value;
+ BezierHandle {
+ dt: span / 3.0,
+ dv: value_delta / 3.0,
+ }
+}
+
+pub fn default_left_handle(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> BezierHandle {
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ let value_delta = right_key.value - left_key.value;
+ BezierHandle {
+ dt: -span / 3.0,
+ dv: -value_delta / 3.0,
+ }
+}
+
+pub fn segment_handles(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> (BezierHandle, BezierHandle) {
+ let right_handle = left_key
+ .right_handle
+ .map(BezierHandle::from)
+ .unwrap_or_else(|| default_right_handle(left_key, right_key));
+ let left_handle = right_key
+ .left_handle
+ .map(BezierHandle::from)
+ .unwrap_or_else(|| default_left_handle(left_key, right_key));
+ (right_handle, left_handle)
+}
+
+pub fn solve_bezier_progress_for_time(
+ time: MediaTime,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> f64 {
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ let target = time.as_ticks() as f64;
+ let p0 = left_key.time.as_ticks() as f64;
+ let p3 = right_key.time.as_ticks() as f64;
+ let mut lower = 0.0;
+ let mut upper = 1.0;
+
+ for _ in 0..BEZIER_SOLVE_ITERATIONS {
+ let mid = (lower + upper) / 2.0;
+ let estimate = bezier_point(mid, p0, p0 + right_handle.dt, p3 + left_handle.dt, p3);
+ if estimate < target {
+ lower = mid;
+ } else {
+ upper = mid;
+ }
+ }
+
+ (lower + upper) / 2.0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ScalarSegmentType, TangentMode};
+
+ fn key(time: i64, value: f64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: format!("k{time}"),
+ time: MediaTime::from_ticks(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Bezier,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ #[test]
+ fn bezier_point_evaluates_cubic() {
+ assert_eq!(bezier_point(0.0, 0.0, 1.0, 2.0, 3.0), 0.0);
+ assert_eq!(bezier_point(1.0, 0.0, 1.0, 2.0, 3.0), 3.0);
+ assert_eq!(bezier_point(0.5, 0.0, 0.0, 1.0, 1.0), 0.5);
+ }
+
+ #[test]
+ fn default_handles_split_span_into_thirds() {
+ let left = key(0, 10.0);
+ let right = key(90, 40.0);
+ let right_handle = default_right_handle(&left, &right);
+ let left_handle = default_left_handle(&left, &right);
+ assert_eq!(right_handle.dt, 30.0);
+ assert_eq!(right_handle.dv, 10.0);
+ assert_eq!(left_handle.dt, -30.0);
+ assert_eq!(left_handle.dv, -10.0);
+ }
+
+ #[test]
+ fn solve_progress_is_linear_for_diagonal_control_points() {
+ let left = key(0, 0.0);
+ let right = key(100, 100.0);
+ let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(50), &left, &right);
+ assert!((progress - 0.5).abs() < 1e-6);
+ let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right);
+ assert!((progress - 0.25).abs() < 1e-6);
+ }
+
+ #[test]
+ fn solve_progress_respects_flat_tangents() {
+ let mut left = key(0, 0.0);
+ left.right_handle = Some(CurveHandle {
+ dt: MediaTime::from_ticks(0),
+ dv: 0.0,
+ });
+ let mut right = key(100, 100.0);
+ right.left_handle = Some(CurveHandle {
+ dt: MediaTime::from_ticks(0),
+ dv: 0.0,
+ });
+ let early = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right);
+ let late = solve_bezier_progress_for_time(MediaTime::from_ticks(75), &left, &right);
+ assert!(early < 0.25);
+ assert!(late > 0.75);
+ }
+}
diff --git a/rust/crates/animation/src/channel_data.rs b/rust/crates/animation/src/channel_data.rs
new file mode 100644
index 00000000..a05396e5
--- /dev/null
+++ b/rust/crates/animation/src/channel_data.rs
@@ -0,0 +1,92 @@
+use scene::{AnimationChannel, ChannelData, CompositeChannelData};
+
+const LEGACY_ANIMATION_STORAGE_KEYS: [&str; 2] = ["bindings", "channels"];
+
+pub fn is_animation_storage_key(key: &str) -> bool {
+ !LEGACY_ANIMATION_STORAGE_KEYS.contains(&key)
+}
+
+fn component_order(key: &str) -> usize {
+ match key {
+ "value" => 0,
+ "r" => 1,
+ "g" => 2,
+ "b" => 3,
+ "a" => 4,
+ _ => 5,
+ }
+}
+
+pub fn ordered_composite_entries(data: &CompositeChannelData) -> Vec<(&String, &AnimationChannel)> {
+ let mut entries: Vec<(&String, &AnimationChannel)> = data.iter().collect();
+ entries.sort_by_key(|(key, _)| component_order(key));
+ entries
+}
+
+pub fn get_channels_from_data(data: Option<&ChannelData>) -> Vec<&AnimationChannel> {
+ match data {
+ Some(ChannelData::Single(channel)) => vec![channel],
+ Some(ChannelData::Composite(components)) => ordered_composite_entries(components)
+ .into_iter()
+ .map(|(_, channel)| channel)
+ .collect(),
+ None => Vec::new(),
+ }
+}
+
+pub fn get_channel_entries_from_data(data: Option<&ChannelData>) -> Vec<(String, &AnimationChannel)> {
+ match data {
+ Some(ChannelData::Single(channel)) => vec![("value".to_string(), channel)],
+ Some(ChannelData::Composite(components)) => ordered_composite_entries(components)
+ .into_iter()
+ .map(|(key, channel)| (key.clone(), channel))
+ .collect(),
+ None => Vec::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, DiscreteChannel, ScalarChannel};
+ use std::collections::BTreeMap;
+
+ fn scalar_channel() -> AnimationChannel {
+ AnimationChannel::Scalar(ScalarChannel {
+ keys: Vec::new(),
+ extrapolation: None,
+ })
+ }
+
+ #[test]
+ fn leaf_data_yields_value_entry() {
+ let data = ChannelData::Single(scalar_channel());
+ let entries = get_channel_entries_from_data(Some(&data));
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].0, "value");
+ }
+
+ #[test]
+ fn composite_entries_follow_canonical_component_order() {
+ let mut components = BTreeMap::new();
+ components.insert("a".to_string(), scalar_channel());
+ components.insert("b".to_string(), scalar_channel());
+ components.insert("g".to_string(), scalar_channel());
+ components.insert("r".to_string(), scalar_channel());
+ components.insert(
+ "mode".to_string(),
+ AnimationChannel::Discrete(DiscreteChannel { keys: Vec::new() }),
+ );
+ let data = ChannelData::Composite(components);
+ let entries = get_channel_entries_from_data(Some(&data));
+ let keys: Vec<&str> = entries.iter().map(|(key, _)| key.as_str()).collect();
+ assert_eq!(keys, ["r", "g", "b", "a", "mode"]);
+ }
+
+ #[test]
+ fn legacy_storage_keys_are_rejected() {
+ assert!(!is_animation_storage_key("bindings"));
+ assert!(!is_animation_storage_key("channels"));
+ assert!(is_animation_storage_key("opacity"));
+ }
+}
diff --git a/rust/crates/animation/src/channel_layout.rs b/rust/crates/animation/src/channel_layout.rs
new file mode 100644
index 00000000..59bbc3f6
--- /dev/null
+++ b/rust/crates/animation/src/channel_layout.rs
@@ -0,0 +1,124 @@
+use scene::ParamValue;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+use crate::color::{LinearRgba, parse_color_to_linear_rgba};
+use crate::types::AnimationInterpolation;
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum ChannelValueKind {
+ Scalar,
+ Discrete,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ChannelComponentDefinition {
+ pub key: String,
+ pub value_kind: ChannelValueKind,
+ pub default_interpolation: AnimationInterpolation,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ChannelLayout {
+ Leaf(ChannelComponentDefinition),
+ Composite(Vec<ChannelComponentDefinition>),
+}
+
+impl ChannelLayout {
+ pub fn components(&self) -> &[ChannelComponentDefinition] {
+ match self {
+ ChannelLayout::Leaf(component) => std::slice::from_ref(component),
+ ChannelLayout::Composite(components) => components,
+ }
+ }
+
+ pub fn decompose(&self, value: &ParamValue) -> Option<BTreeMap<String, ParamValue>> {
+ match self {
+ ChannelLayout::Leaf(component) => {
+ let mut values = BTreeMap::new();
+ values.insert(component.key.clone(), value.clone());
+ Some(values)
+ }
+ ChannelLayout::Composite(_) => {
+ let ParamValue::String(color) = value else {
+ return None;
+ };
+ let LinearRgba { r, g, b, a } = parse_color_to_linear_rgba(color)?;
+ let mut values = BTreeMap::new();
+ values.insert("r".to_string(), ParamValue::Number(r));
+ values.insert("g".to_string(), ParamValue::Number(g));
+ values.insert("b".to_string(), ParamValue::Number(b));
+ values.insert("a".to_string(), ParamValue::Number(a));
+ Some(values)
+ }
+ }
+ }
+}
+
+pub fn number_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Scalar,
+ default_interpolation: AnimationInterpolation::Linear,
+ })
+}
+
+pub fn boolean_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Discrete,
+ default_interpolation: AnimationInterpolation::Hold,
+ })
+}
+
+pub fn string_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Discrete,
+ default_interpolation: AnimationInterpolation::Hold,
+ })
+}
+
+pub fn color_channel_layout() -> ChannelLayout {
+ let component = |key: &str| ChannelComponentDefinition {
+ key: key.to_string(),
+ value_kind: ChannelValueKind::Scalar,
+ default_interpolation: AnimationInterpolation::Linear,
+ };
+ ChannelLayout::Composite(vec![
+ component("r"),
+ component("g"),
+ component("b"),
+ component("a"),
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn leaf_decompose_wraps_value_under_component_key() {
+ let layout = number_channel_layout();
+ let values = layout.decompose(&ParamValue::Number(3.0)).unwrap();
+ assert_eq!(values.get("value"), Some(&ParamValue::Number(3.0)));
+ }
+
+ #[test]
+ fn color_decompose_parses_hex_into_linear_components() {
+ let layout = color_channel_layout();
+ let values = layout
+ .decompose(&ParamValue::String("#ff0000".to_string()))
+ .unwrap();
+ assert_eq!(values.get("r"), Some(&ParamValue::Number(1.0)));
+ assert_eq!(values.get("g"), Some(&ParamValue::Number(0.0)));
+ assert_eq!(values.get("a"), Some(&ParamValue::Number(1.0)));
+ assert!(
+ layout
+ .decompose(&ParamValue::Number(1.0))
+ .is_none()
+ );
+ }
+}
diff --git a/rust/crates/animation/src/color.rs b/rust/crates/animation/src/color.rs
new file mode 100644
index 00000000..03f409a1
--- /dev/null
+++ b/rust/crates/animation/src/color.rs
@@ -0,0 +1,137 @@
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LinearRgba {
+ pub r: f64,
+ pub g: f64,
+ pub b: f64,
+ pub a: f64,
+}
+
+fn clamp_unit(value: f64) -> f64 {
+ value.max(0.0).min(1.0)
+}
+
+fn srgb_to_linear(value: f64) -> f64 {
+ if value <= 0.04045 {
+ value / 12.92
+ } else {
+ ((value + 0.055) / 1.055).powf(2.4)
+ }
+}
+
+fn linear_to_srgb(value: f64) -> f64 {
+ let clamped = clamp_unit(value);
+ if clamped <= 0.003_130_8 {
+ clamped * 12.92
+ } else {
+ 1.055 * clamped.powf(1.0 / 2.4) - 0.055
+ }
+}
+
+fn parse_hex_digit(digit: char) -> Option<f64> {
+ digit.to_digit(16).map(|value| value as f64 / 15.0)
+}
+
+fn parse_hex_pair(pair: &str) -> Option<f64> {
+ u8::from_str_radix(pair, 16)
+ .ok()
+ .map(|value| value as f64 / 255.0)
+}
+
+pub fn parse_color_to_linear_rgba(color: &str) -> Option<LinearRgba> {
+ let hex = color.strip_prefix('#')?;
+ let (r, g, b, a) = match hex.len() {
+ 3 | 4 => {
+ let mut digits = hex.chars();
+ let r = parse_hex_digit(digits.next()?)?;
+ let g = parse_hex_digit(digits.next()?)?;
+ let b = parse_hex_digit(digits.next()?)?;
+ let a = digits.next().map(parse_hex_digit).transpose()?.unwrap_or(1.0);
+ (r, g, b, a)
+ }
+ 6 | 8 => {
+ let r = parse_hex_pair(&hex[0..2])?;
+ let g = parse_hex_pair(&hex[2..4])?;
+ let b = parse_hex_pair(&hex[4..6])?;
+ let a = if hex.len() == 8 {
+ parse_hex_pair(&hex[6..8])?
+ } else {
+ 1.0
+ };
+ (r, g, b, a)
+ }
+ _ => return None,
+ };
+ Some(LinearRgba {
+ r: srgb_to_linear(r),
+ g: srgb_to_linear(g),
+ b: srgb_to_linear(b),
+ a: clamp_unit(a),
+ })
+}
+
+fn hex_component(value: f64) -> u8 {
+ (clamp_unit(value) * 255.0).round() as u8
+}
+
+pub fn format_linear_rgba(color: &LinearRgba) -> String {
+ let r = hex_component(linear_to_srgb(color.r));
+ let g = hex_component(linear_to_srgb(color.g));
+ let b = hex_component(linear_to_srgb(color.b));
+ let a = clamp_unit(color.a);
+ if a < 1.0 {
+ format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, hex_component(a))
+ } else {
+ format!("#{:02x}{:02x}{:02x}", r, g, b)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_hex_colors_to_linear() {
+ let red = parse_color_to_linear_rgba("#ff0000").unwrap();
+ assert_eq!(red.r, 1.0);
+ assert_eq!(red.g, 0.0);
+ assert_eq!(red.b, 0.0);
+ assert_eq!(red.a, 1.0);
+
+ let short = parse_color_to_linear_rgba("#fff").unwrap();
+ assert_eq!(short.r, 1.0);
+ assert_eq!(short.g, 1.0);
+
+ let with_alpha = parse_color_to_linear_rgba("#00000080").unwrap();
+ assert!((with_alpha.a - 128.0 / 255.0).abs() < 1e-9);
+
+ assert!(parse_color_to_linear_rgba("red").is_none());
+ assert!(parse_color_to_linear_rgba("#ff").is_none());
+ }
+
+ #[test]
+ fn formats_linear_colors_as_hex() {
+ let red = LinearRgba {
+ r: 1.0,
+ g: 0.0,
+ b: 0.0,
+ a: 1.0,
+ };
+ assert_eq!(format_linear_rgba(&red), "#ff0000");
+
+ let translucent = LinearRgba {
+ r: 0.0,
+ g: 0.0,
+ b: 0.0,
+ a: 0.5,
+ };
+ assert_eq!(format_linear_rgba(&translucent), "#00000080");
+ }
+
+ #[test]
+ fn hex_round_trips_through_linear_space() {
+ for color in ["#ffffff", "#000000", "#123456", "#abcdef"] {
+ let parsed = parse_color_to_linear_rgba(color).unwrap();
+ assert_eq!(format_linear_rgba(&parsed), color);
+ }
+ }
+}
diff --git a/rust/crates/animation/src/id.rs b/rust/crates/animation/src/id.rs
new file mode 100644
index 00000000..95288660
--- /dev/null
+++ b/rust/crates/animation/src/id.rs
@@ -0,0 +1,61 @@
+use std::cell::Cell;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+thread_local! {
+ static RNG_STATE: Cell<u64> = const { Cell::new(0) };
+}
+
+fn next_u64() -> u64 {
+ RNG_STATE.with(|state| {
+ let mut value = state.get();
+ if value == 0 {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos() as u64)
+ .unwrap_or(0x9e37_79b9_7f4a_7c15);
+ value = nanos
+ ^ (std::process::id() as u64).rotate_left(32)
+ ^ 0x9e37_79b9_7f4a_7c15;
+ }
+ value ^= value << 13;
+ value ^= value >> 7;
+ value ^= value << 17;
+ state.set(value);
+ value
+ })
+}
+
+pub fn generate_uuid() -> String {
+ let mut bytes = [0u8; 16];
+ for chunk in bytes.chunks_mut(8) {
+ let random = next_u64().to_le_bytes();
+ chunk.copy_from_slice(&random[..chunk.len()]);
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ let hex: Vec<String> = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
+ format!(
+ "{}-{}-{}-{}-{}",
+ hex[0..4].join(""),
+ hex[4..6].join(""),
+ hex[6..8].join(""),
+ hex[8..10].join(""),
+ hex[10..16].join("")
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn generates_uuid_shaped_unique_ids() {
+ let id = generate_uuid();
+ let parts: Vec<&str> = id.split('-').collect();
+ assert_eq!(parts.len(), 5);
+ assert_eq!(parts[0].len(), 8);
+ assert_eq!(parts[1].len(), 4);
+ assert_eq!(parts[4].len(), 12);
+ assert_ne!(generate_uuid(), generate_uuid());
+ }
+}
diff --git a/rust/crates/animation/src/interpolation.rs b/rust/crates/animation/src/interpolation.rs
new file mode 100644
index 00000000..85c0735d
--- /dev/null
+++ b/rust/crates/animation/src/interpolation.rs
@@ -0,0 +1,467 @@
+use scene::{
+ AnimationChannel, ChannelExtrapolationMode, CurveHandle, DiscreteChannel, DiscreteValue,
+ ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType,
+};
+use time::MediaTime;
+
+use crate::bezier::{bezier_point, segment_handles, solve_bezier_progress_for_time};
+use crate::types::AnimationInterpolation;
+
+pub fn is_scalar_channel(channel: &AnimationChannel) -> bool {
+ matches!(channel, AnimationChannel::Scalar(_))
+}
+
+fn normalize_right_handle(
+ handle: Option<CurveHandle>,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> Option<CurveHandle> {
+ let handle = handle?;
+ let span = (right_key.time - left_key.time).as_ticks().max(1);
+ Some(CurveHandle {
+ dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(0, span)),
+ dv: handle.dv,
+ })
+}
+
+fn normalize_left_handle(
+ handle: Option<CurveHandle>,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> Option<CurveHandle> {
+ let handle = handle?;
+ let span = (right_key.time - left_key.time).as_ticks().max(1);
+ Some(CurveHandle {
+ dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(-span, 0)),
+ dv: handle.dv,
+ })
+}
+
+pub fn normalize_scalar_channel(channel: &ScalarChannel) -> ScalarChannel {
+ let mut sorted_keys = channel.keys.clone();
+ sorted_keys.sort_by_key(|key| key.time);
+
+ let mut next_keys = Vec::with_capacity(sorted_keys.len());
+ for (index, key) in sorted_keys.iter().enumerate() {
+ let previous_key = index.checked_sub(1).and_then(|i| sorted_keys.get(i));
+ let next_key = sorted_keys.get(index + 1);
+ let left_handle =
+ previous_key.and_then(|previous| normalize_left_handle(key.left_handle, previous, key));
+ let right_handle =
+ next_key.and_then(|next| normalize_right_handle(key.right_handle, key, next));
+ next_keys.push(ScalarAnimationKey {
+ left_handle,
+ right_handle,
+ ..key.clone()
+ });
+ }
+
+ ScalarChannel {
+ keys: next_keys,
+ extrapolation: channel.extrapolation.clone(),
+ }
+}
+
+pub fn normalize_discrete_channel(channel: &DiscreteChannel) -> DiscreteChannel {
+ let mut keys = channel.keys.clone();
+ keys.sort_by_key(|key| key.time);
+ DiscreteChannel { keys }
+}
+
+pub fn normalize_channel(channel: &AnimationChannel) -> AnimationChannel {
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ AnimationChannel::Scalar(normalize_scalar_channel(scalar))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ AnimationChannel::Discrete(normalize_discrete_channel(discrete))
+ }
+ }
+}
+
+pub fn scalar_segment_interpolation(segment: ScalarSegmentType) -> AnimationInterpolation {
+ match segment {
+ ScalarSegmentType::Step => AnimationInterpolation::Hold,
+ ScalarSegmentType::Linear => AnimationInterpolation::Linear,
+ ScalarSegmentType::Bezier => AnimationInterpolation::Bezier,
+ }
+}
+
+fn extrapolate_scalar_edge(
+ mode: ChannelExtrapolationMode,
+ edge_key: &ScalarAnimationKey,
+ neighbor_key: Option<&ScalarAnimationKey>,
+ time: f64,
+) -> f64 {
+ let Some(neighbor_key) = neighbor_key else {
+ return edge_key.value;
+ };
+ if mode == ChannelExtrapolationMode::Hold {
+ return edge_key.value;
+ }
+
+ let span = (neighbor_key.time - edge_key.time).as_ticks() as f64;
+ if span == 0.0 {
+ return edge_key.value;
+ }
+
+ edge_key.value
+ + ((time - edge_key.time.as_ticks() as f64) / span) * (neighbor_key.value - edge_key.value)
+}
+
+fn lerp_number(left_value: f64, right_value: f64, progress: f64) -> f64 {
+ left_value + (right_value - left_value) * progress
+}
+
+pub fn scalar_channel_value_at_time(
+ channel: Option<&ScalarChannel>,
+ time: MediaTime,
+ fallback_value: f64,
+) -> f64 {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value;
+ };
+
+ let normalized = normalize_scalar_channel(channel);
+ let keys = &normalized.keys;
+ let first_key = &keys[0];
+ let last_key = &keys[keys.len() - 1];
+ let t = time.as_ticks() as f64;
+
+ if time <= first_key.time {
+ if time < first_key.time {
+ return extrapolate_scalar_edge(
+ normalized
+ .extrapolation
+ .as_ref()
+ .map(|e| e.before)
+ .unwrap_or(ChannelExtrapolationMode::Hold),
+ first_key,
+ keys.get(1),
+ t,
+ );
+ }
+ return first_key.value;
+ }
+
+ if time >= last_key.time {
+ if time > last_key.time {
+ return extrapolate_scalar_edge(
+ normalized
+ .extrapolation
+ .as_ref()
+ .map(|e| e.after)
+ .unwrap_or(ChannelExtrapolationMode::Hold),
+ last_key,
+ keys.len().checked_sub(2).and_then(|index| keys.get(index)),
+ t,
+ );
+ }
+ return last_key.value;
+ }
+
+ for key_index in 0..keys.len() - 1 {
+ let left_key = &keys[key_index];
+ let right_key = &keys[key_index + 1];
+ if time == right_key.time {
+ return right_key.value;
+ }
+ if time < left_key.time || time > right_key.time {
+ continue;
+ }
+
+ if left_key.segment_to_next == ScalarSegmentType::Step {
+ return left_key.value;
+ }
+
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ if span == 0.0 {
+ return right_key.value;
+ }
+
+ let progress = ((t - left_key.time.as_ticks() as f64) / span).clamp(0.0, 1.0);
+ if left_key.segment_to_next == ScalarSegmentType::Linear {
+ return lerp_number(left_key.value, right_key.value, progress);
+ }
+
+ let curve_progress = solve_bezier_progress_for_time(time, left_key, right_key);
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ return bezier_point(
+ curve_progress,
+ left_key.value,
+ left_key.value + right_handle.dv,
+ right_key.value + left_handle.dv,
+ right_key.value,
+ );
+ }
+
+ last_key.value
+}
+
+pub fn discrete_channel_value_at_time(
+ channel: Option<&DiscreteChannel>,
+ time: MediaTime,
+ fallback_value: DiscreteValue,
+) -> DiscreteValue {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value;
+ };
+
+ let normalized = normalize_discrete_channel(channel);
+ let mut current_value = fallback_value;
+ for key in &normalized.keys {
+ if time < key.time {
+ break;
+ }
+ current_value = key.value.clone();
+ }
+ current_value
+}
+
+pub fn channel_value_at_time(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ fallback_value: &ParamValue,
+) -> ParamValue {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value.clone();
+ };
+
+ if let ParamValue::Number(number_fallback) = fallback_value {
+ return match channel {
+ AnimationChannel::Scalar(scalar) => ParamValue::Number(scalar_channel_value_at_time(
+ Some(scalar),
+ time,
+ *number_fallback,
+ )),
+ AnimationChannel::Discrete(_) => fallback_value.clone(),
+ };
+ }
+
+ match channel {
+ AnimationChannel::Discrete(discrete) => {
+ let discrete_fallback = match fallback_value {
+ ParamValue::Bool(value) => DiscreteValue::Bool(*value),
+ ParamValue::String(value) => DiscreteValue::String(value.clone()),
+ ParamValue::Number(_) => return fallback_value.clone(),
+ };
+ discrete_channel_value_at_time(Some(discrete), time, discrete_fallback).into()
+ }
+ AnimationChannel::Scalar(_) => fallback_value.clone(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ChannelExtrapolation, TangentMode};
+
+ fn scalar_key(id: &str, time: i64, value: f64, segment: ScalarSegmentType) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: MediaTime::from_ticks(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: segment,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn channel(keys: Vec<ScalarAnimationKey>) -> ScalarChannel {
+ ScalarChannel {
+ keys,
+ extrapolation: None,
+ }
+ }
+
+ fn at(time: i64) -> MediaTime {
+ MediaTime::from_ticks(time)
+ }
+
+ #[test]
+ fn empty_channel_returns_fallback() {
+ assert_eq!(scalar_channel_value_at_time(None, at(10), 7.0), 7.0);
+ assert_eq!(
+ scalar_channel_value_at_time(Some(&channel(Vec::new())), at(10), 7.0),
+ 7.0
+ );
+ }
+
+ #[test]
+ fn linear_segment_interpolates() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 0.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 10.0);
+ }
+
+ #[test]
+ fn step_segment_holds_left_value() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 1.0, ScalarSegmentType::Step),
+ scalar_key("b", 100, 9.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(99), 0.0), 1.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 9.0);
+ }
+
+ #[test]
+ fn unsorted_keys_are_sorted_before_sampling() {
+ let channel = channel(vec![
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0);
+ }
+
+ #[test]
+ fn hold_extrapolation_is_the_default() {
+ let channel = channel(vec![
+ scalar_key("a", 10, 1.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 1.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(200), 0.0), 10.0);
+ }
+
+ #[test]
+ fn linear_extrapolation_extends_edge_segments() {
+ let mut channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ channel.extrapolation = Some(ChannelExtrapolation {
+ before: ChannelExtrapolationMode::Linear,
+ after: ChannelExtrapolationMode::Linear,
+ });
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(-50), 0.0), -5.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(150), 0.0), 15.0);
+ }
+
+ #[test]
+ fn linear_extrapolation_holds_with_single_key() {
+ let mut channel = channel(vec![scalar_key("a", 10, 3.0, ScalarSegmentType::Linear)]);
+ channel.extrapolation = Some(ChannelExtrapolation {
+ before: ChannelExtrapolationMode::Linear,
+ after: ChannelExtrapolationMode::Linear,
+ });
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 3.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 3.0);
+ }
+
+ #[test]
+ fn bezier_segment_with_default_handles_matches_diagonal() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier),
+ scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier),
+ ]);
+ let value = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
+ assert!((value - 50.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn bezier_segment_with_flat_handles_eases() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.right_handle = Some(CurveHandle {
+ dt: at(50),
+ dv: 0.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.left_handle = Some(CurveHandle {
+ dt: at(-50),
+ dv: 0.0,
+ });
+ let channel = channel(vec![left, right]);
+ let quarter = scalar_channel_value_at_time(Some(&channel), at(25), 0.0);
+ let mid = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
+ assert!(quarter < 25.0);
+ assert!((mid - 50.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn handles_are_clamped_to_segment_span() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.right_handle = Some(CurveHandle {
+ dt: at(500),
+ dv: 1.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.left_handle = Some(CurveHandle {
+ dt: at(50),
+ dv: 1.0,
+ });
+ let normalized = normalize_scalar_channel(&channel(vec![left, right]));
+ assert_eq!(normalized.keys[0].right_handle.unwrap().dt, at(100));
+ assert_eq!(normalized.keys[1].left_handle.unwrap().dt, at(0));
+ }
+
+ #[test]
+ fn edge_keys_drop_outer_handles() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.left_handle = Some(CurveHandle {
+ dt: at(-10),
+ dv: 1.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.right_handle = Some(CurveHandle {
+ dt: at(10),
+ dv: 1.0,
+ });
+ let normalized = normalize_scalar_channel(&channel(vec![left, right]));
+ assert_eq!(normalized.keys[0].left_handle, None);
+ assert_eq!(normalized.keys[1].right_handle, None);
+ }
+
+ #[test]
+ fn discrete_channel_returns_last_key_at_or_before_time() {
+ let channel = 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::Bool(true),
+ },
+ ],
+ };
+ let fallback = DiscreteValue::Bool(false);
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(-1), fallback.clone()),
+ fallback
+ );
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(50), fallback.clone()),
+ DiscreteValue::String("x".to_string())
+ );
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(100), fallback.clone()),
+ DiscreteValue::Bool(true)
+ );
+ }
+
+ #[test]
+ fn channel_value_at_time_dispatches_on_fallback_type() {
+ let scalar = AnimationChannel::Scalar(channel(vec![scalar_key(
+ "a",
+ 0,
+ 2.0,
+ ScalarSegmentType::Linear,
+ )]));
+ assert_eq!(
+ channel_value_at_time(Some(&scalar), at(0), &ParamValue::Number(0.0)),
+ ParamValue::Number(2.0)
+ );
+ assert_eq!(
+ channel_value_at_time(Some(&scalar), at(0), &ParamValue::Bool(true)),
+ ParamValue::Bool(true)
+ );
+ }
+}
diff --git a/rust/crates/animation/src/keyframes.rs b/rust/crates/animation/src/keyframes.rs
new file mode 100644
index 00000000..0ce938f9
--- /dev/null
+++ b/rust/crates/animation/src/keyframes.rs
@@ -0,0 +1,1150 @@
+use std::collections::{BTreeMap, HashMap};
+
+use scene::{
+ AnimationChannel, ChannelData, DiscreteAnimationKey, DiscreteChannel, DiscreteValue,
+ ElementAnimations, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType,
+ TangentMode,
+};
+use time::MediaTime;
+
+use crate::bezier::{segment_handles, solve_bezier_progress_for_time};
+use crate::channel_data::{
+ get_channel_entries_from_data, get_channels_from_data, is_animation_storage_key,
+};
+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,
+};
+use crate::types::{AnimationInterpolation, ScalarCurveKeyframePatch};
+
+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;
+}
+
+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_data(data: Option<&ChannelData>) -> bool {
+ get_channels_from_data(data)
+ .iter()
+ .any(|channel| has_channel_keys(Some(channel)))
+}
+
+pub fn to_animation(animations: ElementAnimations) -> Option<ElementAnimations> {
+ let next: ElementAnimations = animations
+ .into_iter()
+ .filter(|(key, data)| is_animation_storage_key(key) && has_channel_data(Some(data)))
+ .collect();
+ if next.is_empty() { None } else { Some(next) }
+}
+
+fn get_channel_from_data<'a>(
+ data: Option<&'a ChannelData>,
+ component_key: &str,
+) -> Option<&'a AnimationChannel> {
+ match data {
+ Some(ChannelData::Single(channel)) => {
+ if component_key == "value" { Some(channel) } else { None }
+ }
+ Some(ChannelData::Composite(components)) => components.get(component_key),
+ None => None,
+ }
+}
+
+fn get_primary_component_key(channel_layout: &ChannelLayout) -> String {
+ channel_layout
+ .components()
+ .first()
+ .map(|component| component.key.clone())
+ .unwrap_or_else(|| "value".to_string())
+}
+
+fn get_primary_channel_from_data<'a>(
+ data: Option<&'a ChannelData>,
+ channel_layout: &ChannelLayout,
+) -> Option<&'a AnimationChannel> {
+ get_channel_from_data(data, &get_primary_component_key(channel_layout))
+}
+
+fn set_channel_in_data(
+ data: Option<ChannelData>,
+ component_key: &str,
+ channel: Option<AnimationChannel>,
+) -> Option<ChannelData> {
+ if component_key == "value" {
+ return channel.map(ChannelData::Single);
+ }
+
+ let mut components = match data {
+ Some(ChannelData::Composite(components)) => components,
+ _ => BTreeMap::new(),
+ };
+ match channel {
+ Some(channel) if has_channel_keys(Some(&channel)) => {
+ components.insert(component_key.to_string(), channel);
+ }
+ _ => {
+ components.remove(component_key);
+ }
+ }
+ if components.is_empty() {
+ None
+ } else {
+ Some(ChannelData::Composite(components))
+ }
+}
+
+fn scalar_segment_type(interpolation: AnimationInterpolation) -> ScalarSegmentType {
+ match interpolation {
+ AnimationInterpolation::Hold => ScalarSegmentType::Step,
+ AnimationInterpolation::Bezier => ScalarSegmentType::Bezier,
+ AnimationInterpolation::Linear => ScalarSegmentType::Linear,
+ }
+}
+
+fn create_scalar_key(
+ id: String,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ previous_key: Option<&ScalarAnimationKey>,
+) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id,
+ time,
+ value,
+ left_handle: previous_key.and_then(|key| key.left_handle),
+ right_handle: previous_key.and_then(|key| key.right_handle),
+ segment_to_next: previous_key.map(|key| key.segment_to_next).unwrap_or_else(|| {
+ scalar_segment_type(interpolation.unwrap_or(AnimationInterpolation::Linear))
+ }),
+ tangent_mode: previous_key
+ .map(|key| key.tangent_mode)
+ .unwrap_or(TangentMode::Flat),
+ }
+}
+
+fn channel_key_ids_and_times(channel: &AnimationChannel) -> Vec<(String, MediaTime)> {
+ match channel {
+ AnimationChannel::Scalar(scalar) => scalar
+ .keys
+ .iter()
+ .map(|key| (key.id.clone(), key.time))
+ .collect(),
+ AnimationChannel::Discrete(discrete) => discrete
+ .keys
+ .iter()
+ .map(|key| (key.id.clone(), key.time))
+ .collect(),
+ }
+}
+
+fn get_target_key_metadata(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ keyframe_id: Option<&str>,
+) -> (String, MediaTime) {
+ let normalized = channel.map(normalize_channel);
+ let keys = normalized
+ .as_ref()
+ .map(channel_key_ids_and_times)
+ .unwrap_or_default();
+
+ if let Some(id) = keyframe_id {
+ if let Some((found_id, _)) = keys.iter().find(|(key_id, _)| key_id == id) {
+ return (found_id.clone(), time);
+ }
+ }
+
+ if let Some((found_id, found_time)) = keys.iter().find(|(_, key_time)| *key_time == time) {
+ return (found_id.clone(), *found_time);
+ }
+
+ (
+ keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ )
+}
+
+pub fn upsert_discrete_channel_key(
+ channel: Option<&DiscreteChannel>,
+ time: MediaTime,
+ value: DiscreteValue,
+ keyframe_id: Option<&str>,
+) -> DiscreteChannel {
+ let normalized = normalize_discrete_channel(&channel.cloned().unwrap_or(DiscreteChannel {
+ keys: Vec::new(),
+ }));
+ let mut keys = normalized.keys;
+
+ if let Some(id) = keyframe_id {
+ if let Some(index) = keys.iter().position(|key| key.id == id) {
+ keys[index] = DiscreteAnimationKey {
+ id: keys[index].id.clone(),
+ time,
+ value,
+ };
+ return normalize_discrete_channel(&DiscreteChannel { keys });
+ }
+ }
+
+ if let Some(index) = keys.iter().position(|key| key.time == time) {
+ keys[index] = DiscreteAnimationKey {
+ id: keys[index].id.clone(),
+ time: keys[index].time,
+ value,
+ };
+ return normalize_discrete_channel(&DiscreteChannel { keys });
+ }
+
+ keys.push(DiscreteAnimationKey {
+ id: keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ value,
+ });
+ normalize_discrete_channel(&DiscreteChannel { keys })
+}
+
+fn replace_scalar_key_at(
+ keys: &mut [ScalarAnimationKey],
+ index: usize,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ keep_existing_time: bool,
+) {
+ let mut previous_key = keys[index].clone();
+ if let Some(interpolation) = interpolation {
+ previous_key.segment_to_next = scalar_segment_type(interpolation);
+ }
+ let key_time = if keep_existing_time {
+ previous_key.time
+ } else {
+ time
+ };
+ keys[index] = create_scalar_key(
+ previous_key.id.clone(),
+ key_time,
+ value,
+ interpolation,
+ Some(&previous_key),
+ );
+}
+
+pub fn upsert_scalar_channel_key(
+ channel: Option<&ScalarChannel>,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ default_interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+) -> ScalarChannel {
+ let normalized = normalize_scalar_channel(&channel.cloned().unwrap_or(ScalarChannel {
+ keys: Vec::new(),
+ extrapolation: None,
+ }));
+ let mut keys = normalized.keys;
+
+ if let Some(id) = keyframe_id {
+ if let Some(index) = keys.iter().position(|key| key.id == id) {
+ replace_scalar_key_at(&mut keys, index, time, value, interpolation, false);
+ return normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ });
+ }
+ }
+
+ if let Some(index) = keys.iter().position(|key| key.time == time) {
+ replace_scalar_key_at(&mut keys, index, time, value, interpolation, true);
+ return normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ });
+ }
+
+ keys.push(create_scalar_key(
+ keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ value,
+ interpolation.or(default_interpolation),
+ None,
+ ));
+ normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ })
+}
+
+pub fn get_channel<'a>(
+ animations: Option<&'a ElementAnimations>,
+ property_path: &str,
+) -> Option<&'a AnimationChannel> {
+ let data = animations?.get(property_path);
+ match data {
+ ChannelData::Single(channel) => Some(channel),
+ ChannelData::Composite(_) => get_channels_from_data(Some(data)).into_iter().next(),
+ }
+}
+
+pub fn upsert_path_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ time: MediaTime,
+ value: &ParamValue,
+ interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+ channel_layout: &ChannelLayout,
+ coerce_value: impl Fn(&ParamValue) -> Option<ParamValue>,
+) -> Option<ElementAnimations> {
+ let coerced_value = coerce_value(value)?;
+
+ 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 mut next_data = current_data.clone();
+ for component in channel_layout.components() {
+ let component_key = component.key.as_str();
+ let Some(next_value) = component_values.get(component_key) else {
+ continue;
+ };
+ let current_channel = get_channel_from_data(current_data.as_ref(), component_key);
+
+ match component.value_kind {
+ ChannelValueKind::Discrete => {
+ let discrete_value = match next_value {
+ ParamValue::String(value) => DiscreteValue::String(value.clone()),
+ ParamValue::Bool(value) => DiscreteValue::Bool(*value),
+ ParamValue::Number(_) => continue,
+ };
+ let discrete_channel = match current_channel {
+ Some(AnimationChannel::Discrete(discrete)) => Some(discrete),
+ _ => None,
+ };
+ let next_channel = upsert_discrete_channel_key(
+ discrete_channel,
+ target_time,
+ discrete_value,
+ Some(&target_id),
+ );
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ Some(AnimationChannel::Discrete(next_channel)),
+ );
+ }
+ ChannelValueKind::Scalar => {
+ let ParamValue::Number(number_value) = next_value else {
+ continue;
+ };
+ let scalar_channel = match current_channel {
+ Some(AnimationChannel::Scalar(scalar)) => Some(scalar),
+ _ => None,
+ };
+ let next_channel = upsert_scalar_channel_key(
+ scalar_channel,
+ target_time,
+ *number_value,
+ interpolation,
+ Some(component.default_interpolation),
+ Some(&target_id),
+ );
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ Some(AnimationChannel::Scalar(next_channel)),
+ );
+ }
+ }
+ }
+
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn upsert_keyframe(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ value: &ParamValue,
+ interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match value {
+ ParamValue::String(value) => {
+ let discrete = match channel {
+ AnimationChannel::Discrete(discrete) => Some(discrete),
+ _ => None,
+ };
+ Some(AnimationChannel::Discrete(upsert_discrete_channel_key(
+ discrete,
+ time,
+ DiscreteValue::String(value.clone()),
+ keyframe_id,
+ )))
+ }
+ ParamValue::Bool(value) => {
+ let discrete = match channel {
+ AnimationChannel::Discrete(discrete) => Some(discrete),
+ _ => None,
+ };
+ Some(AnimationChannel::Discrete(upsert_discrete_channel_key(
+ discrete,
+ time,
+ DiscreteValue::Bool(*value),
+ keyframe_id,
+ )))
+ }
+ ParamValue::Number(value) => {
+ let scalar = match channel {
+ AnimationChannel::Scalar(scalar) => Some(scalar),
+ _ => None,
+ };
+ Some(AnimationChannel::Scalar(upsert_scalar_channel_key(
+ scalar,
+ time,
+ *value,
+ interpolation,
+ None,
+ keyframe_id,
+ )))
+ }
+ }
+}
+
+pub fn remove_keyframe(
+ channel: Option<&AnimationChannel>,
+ keyframe_id: &str,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ let keys: Vec<ScalarAnimationKey> = scalar
+ .keys
+ .iter()
+ .filter(|key| key.id != keyframe_id)
+ .cloned()
+ .collect();
+ if keys.is_empty() {
+ return None;
+ }
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ let keys: Vec<DiscreteAnimationKey> = discrete
+ .keys
+ .iter()
+ .filter(|key| key.id != keyframe_id)
+ .cloned()
+ .collect();
+ if keys.is_empty() {
+ return None;
+ }
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys },
+ )))
+ }
+ }
+}
+
+pub fn retime_keyframe(
+ channel: Option<&AnimationChannel>,
+ keyframe_id: &str,
+ time: MediaTime,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ let Some(index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else {
+ return Some(channel.clone());
+ };
+ let mut keys = scalar.keys.clone();
+ keys[index].time = time;
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ let Some(index) = discrete.keys.iter().position(|key| key.id == keyframe_id) else {
+ return Some(channel.clone());
+ };
+ let mut keys = discrete.keys.clone();
+ keys[index].time = time;
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys },
+ )))
+ }
+ }
+}
+
+pub fn set_channel(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ channel: Option<&AnimationChannel>,
+) -> Option<ElementAnimations> {
+ set_binding_component_channel(animations, property_path, "value", channel)
+}
+
+pub fn set_binding_component_channel(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+ channel: Option<&AnimationChannel>,
+) -> Option<ElementAnimations> {
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let prepared = channel
+ .filter(|channel| has_channel_keys(Some(channel)))
+ .map(normalize_channel);
+ let data = set_channel_in_data(
+ next_animations.get(property_path).cloned(),
+ component_key,
+ prepared,
+ );
+ match data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn update_scalar_keyframe_curve(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+ keyframe_id: &str,
+ patch: &ScalarCurveKeyframePatch,
+) -> Option<ElementAnimations> {
+ let channel = get_channel_from_data(
+ animations.and_then(|a| a.get(property_path)),
+ component_key,
+ );
+ let Some(AnimationChannel::Scalar(scalar)) = channel else {
+ return animations.cloned();
+ };
+
+ let Some(keyframe_index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else {
+ return animations.cloned();
+ };
+
+ let mut keys = scalar.keys.clone();
+ let current_key = &keys[keyframe_index];
+ keys[keyframe_index] = ScalarAnimationKey {
+ left_handle: patch.left_handle.unwrap_or(current_key.left_handle),
+ right_handle: patch.right_handle.unwrap_or(current_key.right_handle),
+ segment_to_next: patch.segment_to_next.unwrap_or(current_key.segment_to_next),
+ tangent_mode: patch.tangent_mode.unwrap_or(current_key.tangent_mode),
+ ..current_key.clone()
+ };
+
+ set_binding_component_channel(
+ animations,
+ property_path,
+ component_key,
+ Some(&AnimationChannel::Scalar(ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ })),
+ )
+}
+
+fn clone_channel_with_key_ids(
+ channel: &AnimationChannel,
+ key_id_map: &HashMap<String, String>,
+) -> AnimationChannel {
+ let remap = |id: &str| key_id_map.get(id).cloned().unwrap_or_else(|| id.to_string());
+ match channel {
+ AnimationChannel::Scalar(scalar) => AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: scalar
+ .keys
+ .iter()
+ .map(|key| ScalarAnimationKey {
+ id: remap(&key.id),
+ ..key.clone()
+ })
+ .collect(),
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )),
+ AnimationChannel::Discrete(discrete) => {
+ AnimationChannel::Discrete(normalize_discrete_channel(&DiscreteChannel {
+ keys: discrete
+ .keys
+ .iter()
+ .map(|key| DiscreteAnimationKey {
+ id: remap(&key.id),
+ ..key.clone()
+ })
+ .collect(),
+ }))
+ }
+ }
+}
+
+pub fn clone_animations(
+ animations: Option<&ElementAnimations>,
+ regenerate_keyframe_ids: bool,
+) -> Option<ElementAnimations> {
+ let animations = animations?;
+
+ let mut next_animations = animations.clone();
+ for (property_path, data) in animations
+ .iter()
+ .filter(|(key, _)| is_animation_storage_key(key))
+ {
+ let channels = get_channels_from_data(Some(data));
+ let mut key_id_map = HashMap::new();
+ if let Some(primary_channel) = channels.first() {
+ for (id, _) in channel_key_ids_and_times(primary_channel) {
+ let next_id = if regenerate_keyframe_ids {
+ generate_uuid()
+ } else {
+ id.clone()
+ };
+ key_id_map.insert(id, next_id);
+ }
+ }
+
+ let next_data = match data {
+ ChannelData::Single(channel) => {
+ ChannelData::Single(clone_channel_with_key_ids(channel, &key_id_map))
+ }
+ ChannelData::Composite(components) => ChannelData::Composite(
+ components
+ .iter()
+ .map(|(component_key, channel)| {
+ (
+ component_key.clone(),
+ clone_channel_with_key_ids(channel, &key_id_map),
+ )
+ })
+ .collect(),
+ ),
+ };
+ next_animations.insert(property_path.clone(), next_data);
+ }
+
+ to_animation(next_animations)
+}
+
+pub fn clamp_animations_to_duration(
+ animations: Option<&ElementAnimations>,
+ duration: MediaTime,
+) -> Option<ElementAnimations> {
+ let animations = animations?;
+ if duration <= MediaTime::ZERO {
+ return None;
+ }
+ split_animations_at_time_with_options(Some(animations), duration, true).0
+}
+
+#[derive(Clone, Copy, Debug)]
+struct Point {
+ x: f64,
+ y: f64,
+}
+
+fn lerp_point(left: Point, right: Point, progress: f64) -> Point {
+ Point {
+ x: left.x + (right.x - left.x) * progress,
+ y: left.y + (right.y - left.y) * progress,
+ }
+}
+
+pub struct SplitChannelResult {
+ pub left_channel: Option<AnimationChannel>,
+ pub right_channel: Option<AnimationChannel>,
+}
+
+fn split_discrete_channel_at_time(
+ channel: Option<&DiscreteChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return SplitChannelResult {
+ left_channel: None,
+ right_channel: None,
+ };
+ };
+
+ let normalized = normalize_discrete_channel(channel);
+ let mut left_keys: Vec<DiscreteAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time <= split_time)
+ .cloned()
+ .collect();
+ let mut right_keys: Vec<DiscreteAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time >= split_time)
+ .map(|key| DiscreteAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ })
+ .collect();
+
+ 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,
+ split_time,
+ normalized.keys[0].value.clone(),
+ );
+ if !has_boundary_on_left {
+ left_keys.push(DiscreteAnimationKey {
+ id: left_boundary_id.to_string(),
+ time: split_time,
+ value: boundary_value.clone(),
+ });
+ }
+ if !has_boundary_on_right {
+ right_keys.insert(
+ 0,
+ DiscreteAnimationKey {
+ id: right_boundary_id.to_string(),
+ time: MediaTime::ZERO,
+ value: boundary_value,
+ },
+ );
+ }
+ }
+
+ SplitChannelResult {
+ left_channel: if left_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys: left_keys },
+ )))
+ },
+ right_channel: if right_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys: right_keys },
+ )))
+ },
+ }
+}
+
+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>,
+ extrapolation: Option<scene::ChannelExtrapolation>,
+) -> SplitChannelResult {
+ SplitChannelResult {
+ left_channel: if left_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: left_keys,
+ extrapolation: extrapolation.clone(),
+ },
+ )))
+ },
+ right_channel: if right_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: right_keys,
+ extrapolation,
+ },
+ )))
+ },
+ }
+}
+
+fn split_scalar_channel_at_time(
+ channel: Option<&ScalarChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return SplitChannelResult {
+ left_channel: None,
+ right_channel: None,
+ };
+ };
+
+ let normalized = normalize_scalar_channel(channel);
+ let mut left_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time <= split_time)
+ .cloned()
+ .collect();
+ let mut right_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time >= split_time)
+ .map(|key| ScalarAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ })
+ .collect();
+
+ 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);
+ if !include_split_boundary || (has_boundary_on_left && has_boundary_on_right) {
+ return build_split_scalar_result(
+ left_keys,
+ right_keys,
+ normalized.extrapolation.clone(),
+ );
+ }
+
+ for key_index in 0..normalized.keys.len().saturating_sub(1) {
+ let left_key = &normalized.keys[key_index];
+ let right_key = &normalized.keys[key_index + 1];
+ if split_time <= left_key.time || split_time >= right_key.time {
+ continue;
+ }
+
+ let boundary_value = crate::interpolation::scalar_channel_value_at_time(
+ Some(&normalized),
+ split_time,
+ left_key.value,
+ );
+
+ if left_key.segment_to_next == ScalarSegmentType::Bezier {
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ let progress = solve_bezier_progress_for_time(split_time, left_key, right_key);
+ let p0 = Point {
+ x: left_key.time.as_ticks() as f64,
+ y: left_key.value,
+ };
+ let p1 = Point {
+ x: p0.x + right_handle.dt,
+ y: p0.y + right_handle.dv,
+ };
+ let p3 = Point {
+ x: right_key.time.as_ticks() as f64,
+ y: right_key.value,
+ };
+ let p2 = Point {
+ x: p3.x + left_handle.dt,
+ y: p3.y + left_handle.dv,
+ };
+ let q0 = lerp_point(p0, p1, progress);
+ let q1 = lerp_point(p1, p2, progress);
+ let q2 = lerp_point(p2, p3, progress);
+ let r0 = lerp_point(q0, q1, progress);
+ let r1 = lerp_point(q1, q2, progress);
+ let split_point = lerp_point(r0, r1, progress);
+
+ let mut next_left_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time < split_time)
+ .cloned()
+ .collect();
+ next_left_keys.push(ScalarAnimationKey {
+ right_handle: Some(scene::CurveHandle {
+ dt: round_media_time(q0.x - p0.x),
+ dv: q0.y - p0.y,
+ }),
+ ..left_key.clone()
+ });
+ next_left_keys.push(ScalarAnimationKey {
+ id: left_boundary_id.to_string(),
+ time: split_time,
+ value: boundary_value,
+ left_handle: Some(scene::CurveHandle {
+ dt: round_media_time(r0.x - split_point.x),
+ dv: r0.y - split_point.y,
+ }),
+ right_handle: None,
+ segment_to_next: left_key.segment_to_next,
+ tangent_mode: left_key.tangent_mode,
+ });
+
+ let mut next_right_keys: Vec<ScalarAnimationKey> = vec![
+ ScalarAnimationKey {
+ id: right_boundary_id.to_string(),
+ time: MediaTime::ZERO,
+ value: boundary_value,
+ left_handle: None,
+ right_handle: Some(scene::CurveHandle {
+ dt: round_media_time(r1.x - split_point.x),
+ dv: r1.y - split_point.y,
+ }),
+ segment_to_next: ScalarSegmentType::Bezier,
+ tangent_mode: left_key.tangent_mode,
+ },
+ ScalarAnimationKey {
+ time: right_key.time - split_time,
+ left_handle: Some(scene::CurveHandle {
+ dt: round_media_time(q2.x - p3.x),
+ dv: q2.y - p3.y,
+ }),
+ ..right_key.clone()
+ },
+ ];
+ next_right_keys.extend(
+ normalized
+ .keys
+ .iter()
+ .filter(|key| key.time > right_key.time)
+ .map(|key| ScalarAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ }),
+ );
+
+ return SplitChannelResult {
+ left_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: next_left_keys,
+ extrapolation: normalized.extrapolation.clone(),
+ },
+ ))),
+ right_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: next_right_keys,
+ extrapolation: normalized.extrapolation.clone(),
+ },
+ ))),
+ };
+ }
+
+ left_keys.push(create_scalar_key(
+ left_boundary_id.to_string(),
+ split_time,
+ boundary_value,
+ Some(AnimationInterpolation::Linear),
+ None,
+ ));
+ right_keys.insert(
+ 0,
+ create_scalar_key(
+ right_boundary_id.to_string(),
+ MediaTime::ZERO,
+ boundary_value,
+ Some(scalar_segment_interpolation(left_key.segment_to_next)),
+ None,
+ ),
+ );
+ return build_split_scalar_result(
+ left_keys,
+ right_keys,
+ normalized.extrapolation.clone(),
+ );
+ }
+
+ build_split_scalar_result(left_keys, right_keys, normalized.extrapolation.clone())
+}
+
+fn split_channel_at_time(
+ channel: Option<&AnimationChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ match channel {
+ Some(AnimationChannel::Discrete(discrete)) => split_discrete_channel_at_time(
+ Some(discrete),
+ split_time,
+ left_boundary_id,
+ right_boundary_id,
+ include_split_boundary,
+ ),
+ scalar => split_scalar_channel_at_time(
+ match scalar {
+ Some(AnimationChannel::Scalar(scalar)) => Some(scalar),
+ _ => None,
+ },
+ split_time,
+ left_boundary_id,
+ right_boundary_id,
+ include_split_boundary,
+ ),
+ }
+}
+
+pub fn split_animations_at_time_with_options(
+ animations: Option<&ElementAnimations>,
+ split_time: MediaTime,
+ include_split_boundary: bool,
+) -> (Option<ElementAnimations>, Option<ElementAnimations>) {
+ let Some(animations) = animations else {
+ return (None, None);
+ };
+
+ let mut left_animations = ElementAnimations::new();
+ let mut right_animations = ElementAnimations::new();
+
+ for (property_path, data) in animations
+ .iter()
+ .filter(|(key, _)| is_animation_storage_key(key))
+ {
+ let left_boundary_id = generate_uuid();
+ let right_boundary_id = generate_uuid();
+
+ for (component_key, channel) in get_channel_entries_from_data(Some(data)) {
+ let split = split_channel_at_time(
+ Some(channel),
+ split_time,
+ &left_boundary_id,
+ &right_boundary_id,
+ include_split_boundary,
+ );
+ if let Some(left_channel) = split.left_channel {
+ if let Some(data) = set_channel_in_data(
+ left_animations.get(property_path).cloned(),
+ &component_key,
+ Some(left_channel),
+ ) {
+ left_animations.insert(property_path.clone(), data);
+ }
+ }
+ if let Some(right_channel) = split.right_channel {
+ if let Some(data) = set_channel_in_data(
+ right_animations.get(property_path).cloned(),
+ &component_key,
+ Some(right_channel),
+ ) {
+ right_animations.insert(property_path.clone(), data);
+ }
+ }
+ }
+ }
+
+ (to_animation(left_animations), to_animation(right_animations))
+}
+
+pub fn split_animations_at_time(
+ animations: &ElementAnimations,
+ split_time: MediaTime,
+) -> (ElementAnimations, ElementAnimations) {
+ let (left, right) = split_animations_at_time_with_options(Some(animations), split_time, true);
+ (left.unwrap_or_default(), right.unwrap_or_default())
+}
+
+pub fn remove_element_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ keyframe_id: &str,
+) -> Option<ElementAnimations> {
+ let data = animations.and_then(|a| a.get(property_path))?;
+
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let next_data = match data {
+ ChannelData::Single(channel) => remove_keyframe(Some(channel), keyframe_id),
+ ChannelData::Composite(components) => {
+ let mut next_data = Some(data.clone());
+ for (component_key, channel) in components {
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ remove_keyframe(Some(channel), keyframe_id),
+ );
+ }
+ next_data
+ }
+ };
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn retime_element_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ keyframe_id: &str,
+ time: MediaTime,
+) -> Option<ElementAnimations> {
+ let data = animations.and_then(|a| a.get(property_path))?;
+
+ 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::Composite(components) => {
+ let mut next_data = Some(data.clone());
+ for (component_key, channel) in components {
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ retime_keyframe(Some(channel), keyframe_id, time),
+ );
+ }
+ next_data
+ }
+ };
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
diff --git a/rust/crates/animation/src/lib.rs b/rust/crates/animation/src/lib.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/rust/crates/animation/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/rust/crates/animation/src/types.rs b/rust/crates/animation/src/types.rs
new file mode 100644
index 00000000..ca0c3c20
--- /dev/null
+++ b/rust/crates/animation/src/types.rs
@@ -0,0 +1,45 @@
+use scene::{CurveHandle, ParamValue, ScalarSegmentType, TangentMode};
+use serde::{Deserialize, Serialize};
+use time::MediaTime;
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum AnimationInterpolation {
+ Linear,
+ Hold,
+ Bezier,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct NumericSpec {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub min: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub step: Option<f64>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ScalarCurveKeyframePatch {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub left_handle: Option<Option<CurveHandle>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub right_handle: Option<Option<CurveHandle>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub segment_to_next: Option<ScalarSegmentType>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tangent_mode: Option<TangentMode>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ElementKeyframe {
+ pub property_path: String,
+ pub id: String,
+ pub time: MediaTime,
+ pub value: ParamValue,
+ pub interpolation: AnimationInterpolation,
+}