diff options
Diffstat (limited to 'rust/crates/animation/src')
| -rw-r--r-- | rust/crates/animation/src/bezier.rs | 6 | ||||
| -rw-r--r-- | rust/crates/animation/src/color.rs | 5 | ||||
| -rw-r--r-- | rust/crates/animation/src/effect_param_channel.rs | 125 | ||||
| -rw-r--r-- | rust/crates/animation/src/graph_channels.rs | 184 | ||||
| -rw-r--r-- | rust/crates/animation/src/graphic_param_channel.rs | 102 | ||||
| -rw-r--r-- | rust/crates/animation/src/interpolation.rs | 11 | ||||
| -rw-r--r-- | rust/crates/animation/src/keyframe_query.rs | 308 | ||||
| -rw-r--r-- | rust/crates/animation/src/keyframes.rs | 363 | ||||
| -rw-r--r-- | rust/crates/animation/src/lib.rs | 74 | ||||
| -rw-r--r-- | rust/crates/animation/src/params.rs | 445 | ||||
| -rw-r--r-- | rust/crates/animation/src/path.rs | 23 | ||||
| -rw-r--r-- | rust/crates/animation/src/property_groups.rs | 110 | ||||
| -rw-r--r-- | rust/crates/animation/src/resolve.rs | 182 | ||||
| -rw-r--r-- | rust/crates/animation/src/values.rs | 89 |
14 files changed, 1985 insertions, 42 deletions
diff --git a/rust/crates/animation/src/bezier.rs b/rust/crates/animation/src/bezier.rs index 6bec252f..bd433c47 100644 --- a/rust/crates/animation/src/bezier.rs +++ b/rust/crates/animation/src/bezier.rs @@ -140,17 +140,19 @@ mod tests { fn solve_progress_respects_flat_tangents() { let mut left = key(0, 0.0); left.right_handle = Some(CurveHandle { - dt: MediaTime::from_ticks(0), + dt: MediaTime::from_ticks(50), dv: 0.0, }); let mut right = key(100, 100.0); right.left_handle = Some(CurveHandle { - dt: MediaTime::from_ticks(0), + dt: MediaTime::from_ticks(-50), dv: 0.0, }); let early = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right); + let mid = solve_bezier_progress_for_time(MediaTime::from_ticks(50), &left, &right); let late = solve_bezier_progress_for_time(MediaTime::from_ticks(75), &left, &right); assert!(early < 0.25); + assert!((mid - 0.5).abs() < 1e-3); assert!(late > 0.75); } } diff --git a/rust/crates/animation/src/color.rs b/rust/crates/animation/src/color.rs index 03f409a1..b486c869 100644 --- a/rust/crates/animation/src/color.rs +++ b/rust/crates/animation/src/color.rs @@ -45,7 +45,10 @@ pub fn parse_color_to_linear_rgba(color: &str) -> Option<LinearRgba> { 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); + let a = match digits.next() { + Some(digit) => parse_hex_digit(digit)?, + None => 1.0, + }; (r, g, b, a) } 6 | 8 => { diff --git a/rust/crates/animation/src/effect_param_channel.rs b/rust/crates/animation/src/effect_param_channel.rs new file mode 100644 index 00000000..cc949bda --- /dev/null +++ b/rust/crates/animation/src/effect_param_channel.rs @@ -0,0 +1,125 @@ +use scene::{ElementAnimations, ParamValues}; +use time::MediaTime; + +use crate::keyframes::remove_element_keyframe; +use crate::resolve::resolve_animation_path_value_at_time; + +pub const EFFECT_PARAM_PATH_PREFIX: &str = "effects."; +pub const EFFECT_PARAM_PATH_SUFFIX: &str = ".params."; + +pub fn build_effect_param_path(effect_id: &str, param_key: &str) -> String { + format!("{EFFECT_PARAM_PATH_PREFIX}{effect_id}{EFFECT_PARAM_PATH_SUFFIX}{param_key}") +} + +pub fn is_effect_param_path(property_path: &str) -> bool { + property_path.starts_with(EFFECT_PARAM_PATH_PREFIX) + && property_path.contains(EFFECT_PARAM_PATH_SUFFIX) +} + +pub fn parse_effect_param_path(property_path: &str) -> Option<(String, String)> { + if !is_effect_param_path(property_path) { + return None; + } + let without_prefix = &property_path[EFFECT_PARAM_PATH_PREFIX.len()..]; + let separator_index = without_prefix.find(EFFECT_PARAM_PATH_SUFFIX)?; + if separator_index == 0 { + return None; + } + let effect_id = &without_prefix[..separator_index]; + let param_key = &without_prefix[separator_index + EFFECT_PARAM_PATH_SUFFIX.len()..]; + if effect_id.is_empty() || param_key.is_empty() { + return None; + } + Some((effect_id.to_string(), param_key.to_string())) +} + +pub fn resolve_effect_params_at_time( + effect_id: &str, + params: &ParamValues, + animations: Option<&ElementAnimations>, + local_time: MediaTime, +) -> ParamValues { + let safe_local_time = local_time.max(MediaTime::ZERO); + let mut resolved = ParamValues::new(); + + for (param_key, static_value) in params { + let path = build_effect_param_path(effect_id, param_key); + let value = if animations.and_then(|a| a.get(&path)).is_some() { + resolve_animation_path_value_at_time(animations, &path, safe_local_time, static_value) + } else { + static_value.clone() + }; + resolved.insert(param_key.clone(), value); + } + + resolved +} + +pub fn remove_effect_param_keyframe( + animations: Option<&ElementAnimations>, + effect_id: &str, + param_key: &str, + keyframe_id: &str, +) -> Option<ElementAnimations> { + remove_element_keyframe( + animations, + &build_effect_param_path(effect_id, param_key), + keyframe_id, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{AnimationChannel, ChannelData, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + + #[test] + fn builds_and_parses_effect_param_paths() { + let path = build_effect_param_path("glow", "intensity"); + assert_eq!(path, "effects.glow.params.intensity"); + assert!(is_effect_param_path(&path)); + assert!(!is_effect_param_path("params.blur")); + assert_eq!( + parse_effect_param_path(&path), + Some(("glow".to_string(), "intensity".to_string())) + ); + assert_eq!(parse_effect_param_path("effects..params.x"), None); + assert_eq!(parse_effect_param_path("effects.glow.params."), None); + assert_eq!(parse_effect_param_path("opacity"), None); + } + + #[test] + fn resolves_effect_params_and_removes_keyframes() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![ScalarAnimationKey { + id: "k1".to_string(), + time: MediaTime::from_ticks(0), + value: 3.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }], + extrapolation: None, + }); + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert( + "effects.glow.params.intensity".to_string(), + ChannelData::Single(channel), + ); + + let mut params: ParamValues = BTreeMap::new(); + params.insert("intensity".to_string(), ParamValue::Number(1.0)); + let resolved = resolve_effect_params_at_time( + "glow", + ¶ms, + Some(&animations), + MediaTime::from_ticks(0), + ); + assert_eq!(resolved.get("intensity"), Some(&ParamValue::Number(3.0))); + + let after = remove_effect_param_keyframe(Some(&animations), "glow", "intensity", "k1"); + assert!(after.is_none()); + } +} diff --git a/rust/crates/animation/src/graph_channels.rs b/rust/crates/animation/src/graph_channels.rs new file mode 100644 index 00000000..ea707a4f --- /dev/null +++ b/rust/crates/animation/src/graph_channels.rs @@ -0,0 +1,184 @@ +use scene::{AnimationChannel, ChannelData, ElementAnimations, ScalarAnimationKey, ScalarChannel}; +use serde::{Deserialize, Serialize}; + +use crate::channel_data::get_channel_entries_from_data; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ChannelEasingMode { + Independent, + Shared, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScalarGraphChannel { + pub property_path: String, + pub component_key: String, + pub channel: ScalarChannel, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditableScalarChannels { + pub easing_mode: ChannelEasingMode, + pub channels: Vec<ScalarGraphChannel>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScalarGraphKeyframeContext { + pub property_path: String, + pub component_key: String, + pub channel: ScalarChannel, + pub keyframe: ScalarAnimationKey, + pub keyframe_index: usize, + pub previous_key: Option<ScalarAnimationKey>, + pub next_key: Option<ScalarAnimationKey>, +} + +fn easing_mode_for_channel_data(data: &ChannelData) -> ChannelEasingMode { + match data { + ChannelData::Composite(components) + if ["r", "g", "b", "a"] + .iter() + .all(|key| components.contains_key(*key)) => + { + ChannelEasingMode::Shared + } + _ => ChannelEasingMode::Independent, + } +} + +pub fn get_editable_scalar_channels( + animations: Option<&ElementAnimations>, + property_path: &str, +) -> Option<EditableScalarChannels> { + let data = animations?.get(property_path)?; + + let channels = get_channel_entries_from_data(Some(data)) + .into_iter() + .filter_map(|(component_key, channel)| match channel { + AnimationChannel::Scalar(scalar) => Some(ScalarGraphChannel { + property_path: property_path.to_string(), + component_key, + channel: scalar.clone(), + }), + AnimationChannel::Discrete(_) => None, + }) + .collect(); + + Some(EditableScalarChannels { + easing_mode: easing_mode_for_channel_data(data), + channels, + }) +} + +pub fn get_editable_scalar_channel( + animations: Option<&ElementAnimations>, + property_path: &str, + component_key: &str, +) -> Option<ScalarGraphChannel> { + get_editable_scalar_channels(animations, property_path)? + .channels + .into_iter() + .find(|channel| channel.component_key == component_key) +} + +pub fn get_scalar_keyframe_context( + animations: Option<&ElementAnimations>, + property_path: &str, + component_key: &str, + keyframe_id: &str, +) -> Option<ScalarGraphKeyframeContext> { + let graph_channel = get_editable_scalar_channel(animations, property_path, component_key)?; + let keyframe_index = graph_channel + .channel + .keys + .iter() + .position(|key| key.id == keyframe_id)?; + + Some(ScalarGraphKeyframeContext { + property_path: graph_channel.property_path, + component_key: graph_channel.component_key, + keyframe: graph_channel.channel.keys[keyframe_index].clone(), + keyframe_index, + previous_key: keyframe_index + .checked_sub(1) + .and_then(|index| graph_channel.channel.keys.get(index)) + .cloned(), + next_key: graph_channel.channel.keys.get(keyframe_index + 1).cloned(), + channel: graph_channel.channel, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + use time::MediaTime; + + fn key(id: &str, time: i64) -> ScalarAnimationKey { + ScalarAnimationKey { + id: id.to_string(), + time: MediaTime::from_ticks(time), + value: 0.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + } + } + + fn scalar_channel(keys: Vec<ScalarAnimationKey>) -> AnimationChannel { + AnimationChannel::Scalar(ScalarChannel { + keys, + extrapolation: None, + }) + } + + #[test] + fn exposes_scalar_channels_for_editing() { + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert( + "opacity".to_string(), + ChannelData::Single(scalar_channel(vec![key("a", 0), key("b", 10)])), + ); + let editable = get_editable_scalar_channels(Some(&animations), "opacity").unwrap(); + assert_eq!(editable.easing_mode, ChannelEasingMode::Independent); + assert_eq!(editable.channels.len(), 1); + assert_eq!(editable.channels[0].component_key, "value"); + + let context = + get_scalar_keyframe_context(Some(&animations), "opacity", "value", "b").unwrap(); + assert_eq!(context.keyframe_index, 1); + assert_eq!(context.previous_key.as_ref().map(|key| key.id.as_str()), Some("a")); + assert_eq!(context.next_key, None); + } + + #[test] + fn color_composites_share_easing() { + let mut components = BTreeMap::new(); + for component_key in ["r", "g", "b", "a"] { + components.insert(component_key.to_string(), scalar_channel(vec![key("k", 0)])); + } + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert("color".to_string(), ChannelData::Composite(components)); + let editable = get_editable_scalar_channels(Some(&animations), "color").unwrap(); + assert_eq!(editable.easing_mode, ChannelEasingMode::Shared); + assert_eq!(editable.channels.len(), 4); + } + + #[test] + fn discrete_channels_are_not_editable_scalars() { + let channel = AnimationChannel::Discrete(scene::DiscreteChannel { + keys: vec![scene::DiscreteAnimationKey { + id: "d".to_string(), + time: MediaTime::from_ticks(0), + value: scene::DiscreteValue::Bool(true), + }], + }); + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert("params.visible".to_string(), ChannelData::Single(channel)); + let editable = get_editable_scalar_channels(Some(&animations), "params.visible").unwrap(); + assert!(editable.channels.is_empty()); + } +} diff --git a/rust/crates/animation/src/graphic_param_channel.rs b/rust/crates/animation/src/graphic_param_channel.rs new file mode 100644 index 00000000..776adde8 --- /dev/null +++ b/rust/crates/animation/src/graphic_param_channel.rs @@ -0,0 +1,102 @@ +use scene::{ElementAnimations, ParamValues}; +use time::MediaTime; + +use crate::params::ParamDefinition; +use crate::resolve::resolve_animation_path_value_at_time; + +pub const GRAPHIC_PARAM_PATH_PREFIX: &str = "params."; + +pub fn build_graphic_param_path(param_key: &str) -> String { + format!("{GRAPHIC_PARAM_PATH_PREFIX}{param_key}") +} + +pub fn is_graphic_param_path(property_path: &str) -> bool { + property_path.starts_with(GRAPHIC_PARAM_PATH_PREFIX) +} + +pub fn parse_graphic_param_path(property_path: &str) -> Option<String> { + let param_key = property_path.strip_prefix(GRAPHIC_PARAM_PATH_PREFIX)?; + if param_key.is_empty() { + return None; + } + Some(param_key.to_string()) +} + +pub fn resolve_graphic_params_at_time( + params: &ParamValues, + definitions: &[ParamDefinition], + animations: Option<&ElementAnimations>, + local_time: MediaTime, +) -> ParamValues { + let mut resolved = params.clone(); + let safe_local_time = local_time.max(MediaTime::ZERO); + + for param in definitions { + let path = build_graphic_param_path(param.key()); + if animations.and_then(|a| a.get(&path)).is_none() { + continue; + } + let fallback_value = params + .get(param.key()) + .cloned() + .unwrap_or_else(|| param.default_value()); + resolved.insert( + param.key().to_string(), + resolve_animation_path_value_at_time(animations, &path, safe_local_time, &fallback_value), + ); + } + + resolved +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{AnimationChannel, ChannelData, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + + #[test] + fn builds_and_parses_graphic_param_paths() { + assert_eq!(build_graphic_param_path("blur"), "params.blur"); + assert!(is_graphic_param_path("params.blur")); + assert!(!is_graphic_param_path("effects.x.params.y")); + assert_eq!(parse_graphic_param_path("params.blur"), Some("blur".to_string())); + assert_eq!(parse_graphic_param_path("params."), None); + assert_eq!(parse_graphic_param_path("opacity"), None); + } + + #[test] + fn resolves_animated_params_over_static_values() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![ScalarAnimationKey { + id: "a".to_string(), + time: MediaTime::from_ticks(0), + value: 5.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }], + extrapolation: None, + }); + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert("params.blur".to_string(), ChannelData::Single(channel)); + + let mut params: ParamValues = BTreeMap::new(); + params.insert("blur".to_string(), ParamValue::Number(1.0)); + params.insert("other".to_string(), ParamValue::Number(2.0)); + + let definitions = vec![ + ParamDefinition::number("blur", "Blur", 0.0, 0.0, None, 1.0), + ParamDefinition::number("other", "Other", 0.0, 0.0, None, 1.0), + ]; + let resolved = resolve_graphic_params_at_time( + ¶ms, + &definitions, + Some(&animations), + MediaTime::from_ticks(0), + ); + assert_eq!(resolved.get("blur"), Some(&ParamValue::Number(5.0))); + assert_eq!(resolved.get("other"), Some(&ParamValue::Number(2.0))); + } +} diff --git a/rust/crates/animation/src/interpolation.rs b/rust/crates/animation/src/interpolation.rs index 85c0735d..2388fa52 100644 --- a/rust/crates/animation/src/interpolation.rs +++ b/rust/crates/animation/src/interpolation.rs @@ -223,7 +223,10 @@ pub fn channel_value_at_time( time: MediaTime, fallback_value: &ParamValue, ) -> ParamValue { - let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else { + let Some(channel) = channel.filter(|channel| match channel { + AnimationChannel::Scalar(scalar) => !scalar.keys.is_empty(), + AnimationChannel::Discrete(discrete) => !discrete.keys.is_empty(), + }) else { return fallback_value.clone(); }; @@ -254,7 +257,7 @@ pub fn channel_value_at_time( #[cfg(test)] mod tests { use super::*; - use scene::{ChannelExtrapolation, TangentMode}; + use scene::{ChannelExtrapolation, DiscreteAnimationKey, TangentMode}; fn scalar_key(id: &str, time: i64, value: f64, segment: ScalarSegmentType) -> ScalarAnimationKey { ScalarAnimationKey { @@ -360,7 +363,7 @@ mod tests { 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); + assert!((value - 50.0).abs() < 1e-3); } #[test] @@ -379,7 +382,7 @@ mod tests { 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); + assert!((mid - 50.0).abs() < 1e-3); } #[test] diff --git a/rust/crates/animation/src/keyframe_query.rs b/rust/crates/animation/src/keyframe_query.rs new file mode 100644 index 00000000..fc516292 --- /dev/null +++ b/rust/crates/animation/src/keyframe_query.rs @@ -0,0 +1,308 @@ +use scene::{AnimationChannel, ChannelData, ElementAnimations, ParamValue}; +use time::MediaTime; + +use crate::channel_data::{get_channel_entries_from_data, is_animation_storage_key}; +use crate::color::format_linear_rgba; +use crate::interpolation::{channel_value_at_time, scalar_segment_interpolation}; +use crate::path::is_animation_path; +use crate::types::{AnimationInterpolation, ElementKeyframe}; + +#[derive(Clone, Debug)] +struct ChannelKeyframeMatch { + component_index: usize, + id: String, + time: MediaTime, + interpolation: AnimationInterpolation, +} + +fn channel_keyframe_matches(data: Option<&ChannelData>) -> Vec<ChannelKeyframeMatch> { + get_channel_entries_from_data(data) + .into_iter() + .enumerate() + .flat_map(|(component_index, (_, channel))| { + let matches: Vec<ChannelKeyframeMatch> = match channel { + AnimationChannel::Scalar(scalar) => scalar + .keys + .iter() + .map(|key| ChannelKeyframeMatch { + component_index, + id: key.id.clone(), + time: key.time, + interpolation: scalar_segment_interpolation(key.segment_to_next), + }) + .collect(), + AnimationChannel::Discrete(discrete) => discrete + .keys + .iter() + .map(|key| ChannelKeyframeMatch { + component_index, + id: key.id.clone(), + time: key.time, + interpolation: AnimationInterpolation::Hold, + }) + .collect(), + }; + matches + }) + .collect() +} + +fn unique_channel_keyframe_matches(data: Option<&ChannelData>) -> Vec<ChannelKeyframeMatch> { + let mut sorted = channel_keyframe_matches(data); + sorted.sort_by(|left, right| { + left.time + .cmp(&right.time) + .then(left.component_index.cmp(&right.component_index)) + }); + + let mut unique: Vec<ChannelKeyframeMatch> = Vec::new(); + for keyframe_match in sorted { + match unique.last() { + Some(previous) if previous.time == keyframe_match.time => { + if previous.component_index != 0 && keyframe_match.component_index == 0 { + *unique.last_mut().expect("last exists") = keyframe_match; + } + } + _ => unique.push(keyframe_match), + } + } + unique +} + +fn preferred_channel_keyframe_match( + matches: Vec<ChannelKeyframeMatch>, +) -> Option<ChannelKeyframeMatch> { + matches + .iter() + .find(|keyframe_match| keyframe_match.component_index == 0) + .cloned() + .or_else(|| matches.into_iter().next()) +} + +fn channel_fallback_value(channel: &AnimationChannel) -> ParamValue { + match channel { + AnimationChannel::Scalar(scalar) => scalar + .keys + .first() + .map(|key| ParamValue::Number(key.value)) + .unwrap_or(ParamValue::Number(0.0)), + AnimationChannel::Discrete(discrete) => discrete + .keys + .first() + .map(|key| key.value.clone().into()) + .unwrap_or(ParamValue::Bool(false)), + } +} + +fn channel_value(channel: &AnimationChannel, time: MediaTime) -> ParamValue { + let fallback_value = channel_fallback_value(channel); + channel_value_at_time(Some(channel), time, &fallback_value) +} + +fn composed_channel_data_value_at_time( + data: Option<&ChannelData>, + time: MediaTime, +) -> Option<ParamValue> { + let entries = get_channel_entries_from_data(data); + if entries.is_empty() { + return None; + } + if entries.len() == 1 && entries[0].0 == "value" { + return Some(channel_value(entries[0].1, time)); + } + + let component_values: std::collections::BTreeMap<String, ParamValue> = entries + .iter() + .map(|(component_key, channel)| (component_key.clone(), channel_value(channel, time))) + .collect(); + let ( + Some(ParamValue::Number(r)), + Some(ParamValue::Number(g)), + Some(ParamValue::Number(b)), + Some(ParamValue::Number(a)), + ) = ( + component_values.get("r"), + component_values.get("g"), + component_values.get("b"), + component_values.get("a"), + ) + else { + return None; + }; + Some(ParamValue::String(format_linear_rgba( + &crate::color::LinearRgba { + r: *r, + g: *g, + b: *b, + a: *a, + }, + ))) +} + +fn to_element_keyframe( + data: Option<&ChannelData>, + property_path: &str, + keyframe_match: &ChannelKeyframeMatch, +) -> Option<ElementKeyframe> { + let value = composed_channel_data_value_at_time(data, keyframe_match.time)?; + Some(ElementKeyframe { + property_path: property_path.to_string(), + id: keyframe_match.id.clone(), + time: keyframe_match.time, + value, + interpolation: keyframe_match.interpolation, + }) +} + +pub fn get_element_keyframes(animations: Option<&ElementAnimations>) -> Vec<ElementKeyframe> { + let Some(animations) = animations else { + return Vec::new(); + }; + + animations + .iter() + .filter(|(key, _)| is_animation_storage_key(key)) + .flat_map(|(property_path, data)| { + if !is_animation_path(property_path) { + return Vec::new(); + } + unique_channel_keyframe_matches(Some(data)) + .iter() + .filter_map(|keyframe_match| { + to_element_keyframe(Some(data), property_path, keyframe_match) + }) + .collect() + }) + .collect() +} + +pub fn has_keyframes_for_path( + animations: Option<&ElementAnimations>, + property_path: &str, +) -> bool { + get_channel_entries_from_data(animations.and_then(|a| a.get(property_path))) + .iter() + .any(|(_, channel)| { + match channel { + AnimationChannel::Scalar(scalar) => !scalar.keys.is_empty(), + AnimationChannel::Discrete(discrete) => !discrete.keys.is_empty(), + } + }) +} + +pub fn get_keyframe_at_time( + animations: Option<&ElementAnimations>, + property_path: &str, + time: MediaTime, +) -> Option<ElementKeyframe> { + let data = animations?.get(property_path)?; + let keyframe_match = preferred_channel_keyframe_match( + channel_keyframe_matches(Some(data)) + .into_iter() + .filter(|keyframe_match| keyframe_match.time == time) + .collect(), + )?; + to_element_keyframe(Some(data), property_path, &keyframe_match) +} + +pub fn get_keyframe_by_id( + animations: Option<&ElementAnimations>, + property_path: &str, + keyframe_id: &str, +) -> Option<ElementKeyframe> { + let data = animations?.get(property_path)?; + let keyframe_match = preferred_channel_keyframe_match( + channel_keyframe_matches(Some(data)) + .into_iter() + .filter(|keyframe_match| keyframe_match.id == keyframe_id) + .collect(), + )?; + to_element_keyframe(Some(data), property_path, &keyframe_match) +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + + fn key(id: &str, time: i64, value: f64) -> ScalarAnimationKey { + ScalarAnimationKey { + id: id.to_string(), + time: MediaTime::from_ticks(time), + value, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + } + } + + fn animations_with_channel(path: &str, channel: AnimationChannel) -> ElementAnimations { + let mut animations = BTreeMap::new(); + animations.insert(path.to_string(), ChannelData::Single(channel)); + animations + } + + #[test] + fn lists_keyframes_sorted_by_time() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![key("b", 100, 1.0), key("a", 0, 0.0)], + extrapolation: None, + }); + let animations = animations_with_channel("opacity", channel); + let keyframes = get_element_keyframes(Some(&animations)); + assert_eq!(keyframes.len(), 2); + assert_eq!(keyframes[0].id, "a"); + assert_eq!(keyframes[1].id, "b"); + assert_eq!(keyframes[0].value, ParamValue::Number(0.0)); + assert_eq!(keyframes[0].interpolation, AnimationInterpolation::Linear); + } + + #[test] + fn ignores_non_animation_and_legacy_paths() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![key("a", 0, 0.0)], + extrapolation: None, + }); + let animations = animations_with_channel("bindings", channel); + assert!(get_element_keyframes(Some(&animations)).is_empty()); + } + + #[test] + fn finds_keyframe_at_time_and_by_id() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![key("a", 0, 0.0), key("b", 100, 1.0)], + extrapolation: None, + }); + let animations = animations_with_channel("opacity", channel); + let at_time = get_keyframe_at_time(Some(&animations), "opacity", MediaTime::from_ticks(100)); + assert_eq!(at_time.as_ref().map(|k| k.id.as_str()), Some("b")); + assert!(get_keyframe_at_time(Some(&animations), "opacity", MediaTime::from_ticks(50)).is_none()); + let by_id = get_keyframe_by_id(Some(&animations), "opacity", "a"); + assert_eq!(by_id.as_ref().map(|k| k.time), Some(MediaTime::from_ticks(0))); + assert!(has_keyframes_for_path(Some(&animations), "opacity")); + assert!(!has_keyframes_for_path(Some(&animations), "volume")); + } + + #[test] + fn composite_color_data_composes_hex_value() { + let component = |id: &str, time: i64, value: f64| { + AnimationChannel::Scalar(ScalarChannel { + keys: vec![key(id, time, value)], + extrapolation: None, + }) + }; + let mut components = BTreeMap::new(); + components.insert("r".to_string(), component("kr", 0, 1.0)); + components.insert("g".to_string(), component("kg", 0, 0.0)); + components.insert("b".to_string(), component("kb", 0, 0.0)); + components.insert("a".to_string(), component("ka", 0, 1.0)); + let mut animations = BTreeMap::new(); + animations.insert("color".to_string(), ChannelData::Composite(components)); + let keyframes = get_element_keyframes(Some(&animations)); + assert_eq!(keyframes.len(), 1); + assert_eq!(keyframes[0].id, "kr"); + assert_eq!(keyframes[0].value, ParamValue::String("#ff0000".to_string())); + } +} 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()); + } +} diff --git a/rust/crates/animation/src/lib.rs b/rust/crates/animation/src/lib.rs index 8b137891..01f64b57 100644 --- a/rust/crates/animation/src/lib.rs +++ b/rust/crates/animation/src/lib.rs @@ -1 +1,75 @@ +mod bezier; +mod channel_data; +mod channel_layout; +mod color; +mod effect_param_channel; +mod graph_channels; +mod graphic_param_channel; +mod id; +mod interpolation; +mod keyframe_query; +mod keyframes; +mod params; +mod path; +mod property_groups; +mod resolve; +mod types; +mod values; +pub use bezier::{ + BEZIER_SOLVE_ITERATIONS, BezierHandle, bezier_point, default_left_handle, + default_right_handle, segment_handles, solve_bezier_progress_for_time, +}; +pub use channel_data::{ + get_channel_entries_from_data, get_channels_from_data, is_animation_storage_key, + ordered_composite_entries, +}; +pub use channel_layout::{ + ChannelComponentDefinition, ChannelLayout, ChannelValueKind, boolean_channel_layout, + color_channel_layout, number_channel_layout, string_channel_layout, +}; +pub use color::{LinearRgba, format_linear_rgba, parse_color_to_linear_rgba}; +pub use effect_param_channel::{ + EFFECT_PARAM_PATH_PREFIX, EFFECT_PARAM_PATH_SUFFIX, build_effect_param_path, + is_effect_param_path, parse_effect_param_path, remove_effect_param_keyframe, + resolve_effect_params_at_time, +}; +pub use graph_channels::{ + ChannelEasingMode, EditableScalarChannels, ScalarGraphChannel, ScalarGraphKeyframeContext, + get_editable_scalar_channel, get_editable_scalar_channels, get_scalar_keyframe_context, +}; +pub use graphic_param_channel::{ + GRAPHIC_PARAM_PATH_PREFIX, build_graphic_param_path, is_graphic_param_path, + parse_graphic_param_path, resolve_graphic_params_at_time, +}; +pub use id::generate_uuid; +pub use interpolation::{ + channel_value_at_time, discrete_channel_value_at_time, is_scalar_channel, normalize_channel, + normalize_discrete_channel, normalize_scalar_channel, scalar_channel_value_at_time, + scalar_segment_interpolation, +}; +pub use keyframe_query::{ + get_element_keyframes, get_keyframe_at_time, get_keyframe_by_id, has_keyframes_for_path, +}; +pub use keyframes::{ + SplitChannelResult, clamp_animations_to_duration, clone_animations, get_channel, + remove_element_keyframe, remove_keyframe, retime_element_keyframe, retime_keyframe, + set_binding_component_channel, set_channel, split_animations_at_time, + split_animations_at_time_with_options, to_animation, update_scalar_keyframe_curve, + upsert_discrete_channel_key, upsert_keyframe, upsert_path_keyframe, upsert_scalar_channel_key, +}; +pub use params::{ + NumberParamDefinition, ParamDefinition, ParamValueKind, SelectOption, coerce_param_value, + get_param_channel_layout, get_param_default_interpolation, get_param_numeric_range, + get_param_value_kind, snap_to_step, +}; +pub use path::is_animation_path; +pub use property_groups::{ + ANIMATION_PROPERTY_GROUPS, GroupKeyframeRef, animation_property_group_paths, + get_group_keyframes_at_time, has_group_keyframe_at_time, is_animation_property_path, +}; +pub use resolve::{get_element_local_time, resolve_animation_path_value_at_time}; +pub use types::{ + AnimationInterpolation, ElementKeyframe, NumericSpec, ScalarCurveKeyframePatch, +}; +pub use values::{resolve_color_at_time, resolve_number_at_time, resolve_opacity_at_time}; diff --git a/rust/crates/animation/src/params.rs b/rust/crates/animation/src/params.rs new file mode 100644 index 00000000..c677e392 --- /dev/null +++ b/rust/crates/animation/src/params.rs @@ -0,0 +1,445 @@ +use scene::ParamValue; + +use crate::channel_layout::{ + ChannelLayout, boolean_channel_layout, color_channel_layout, number_channel_layout, + string_channel_layout, +}; +use crate::types::{AnimationInterpolation, NumericSpec}; + +#[derive(Clone, Debug, PartialEq)] +pub struct NumberParamDefinition { + pub key: String, + pub label: String, + pub default: f64, + pub min: f64, + pub max: Option<f64>, + pub step: f64, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BooleanParamDefinition { + pub key: String, + pub label: String, + pub default: bool, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ColorParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectOption { + pub value: String, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub options: Vec<SelectOption>, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FontParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ParamDefinition { + Number(NumberParamDefinition), + Boolean(BooleanParamDefinition), + Color(ColorParamDefinition), + Select(SelectParamDefinition), + Text(TextParamDefinition), + Font(FontParamDefinition), +} + +impl ParamDefinition { + pub fn number( + key: &str, + label: &str, + default: f64, + min: f64, + max: Option<f64>, + step: f64, + ) -> Self { + ParamDefinition::Number(NumberParamDefinition { + key: key.to_string(), + label: label.to_string(), + default, + min, + max, + step, + channels: None, + }) + } + + pub fn boolean(key: &str, label: &str, default: bool) -> Self { + ParamDefinition::Boolean(BooleanParamDefinition { + key: key.to_string(), + label: label.to_string(), + default, + channels: None, + }) + } + + pub fn color(key: &str, label: &str, default: &str) -> Self { + ParamDefinition::Color(ColorParamDefinition { + key: key.to_string(), + label: label.to_string(), + default: default.to_string(), + channels: None, + }) + } + + pub fn select(key: &str, label: &str, default: &str, options: Vec<SelectOption>) -> Self { + ParamDefinition::Select(SelectParamDefinition { + key: key.to_string(), + label: label.to_string(), + default: default.to_string(), + options, + channels: None, + }) + } + + pub fn key(&self) -> &str { + match self { + ParamDefinition::Number(param) => ¶m.key, + ParamDefinition::Boolean(param) => ¶m.key, + ParamDefinition::Color(param) => ¶m.key, + ParamDefinition::Select(param) => ¶m.key, + ParamDefinition::Text(param) => ¶m.key, + ParamDefinition::Font(param) => ¶m.key, + } + } + + pub fn default_value(&self) -> ParamValue { + match self { + ParamDefinition::Number(param) => ParamValue::Number(param.default), + ParamDefinition::Boolean(param) => ParamValue::Bool(param.default), + ParamDefinition::Color(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Select(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Text(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Font(param) => ParamValue::String(param.default.clone()), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParamValueKind { + Number, + Color, + Discrete, +} + +pub fn get_param_channel_layout(param: &ParamDefinition) -> ChannelLayout { + match param { + ParamDefinition::Number(param) => param + .channels + .clone() + .unwrap_or_else(number_channel_layout), + ParamDefinition::Boolean(param) => param + .channels + .clone() + .unwrap_or_else(boolean_channel_layout), + ParamDefinition::Color(param) => param.channels.clone().unwrap_or_else(color_channel_layout), + ParamDefinition::Select(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + ParamDefinition::Text(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + ParamDefinition::Font(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + } +} + +pub fn get_param_value_kind(param: &ParamDefinition) -> ParamValueKind { + let layout = get_param_channel_layout(param); + match &layout { + ChannelLayout::Composite(_) => ParamValueKind::Color, + ChannelLayout::Leaf(component) => match component.value_kind { + crate::channel_layout::ChannelValueKind::Scalar => ParamValueKind::Number, + crate::channel_layout::ChannelValueKind::Discrete => ParamValueKind::Discrete, + }, + } +} + +pub fn get_param_default_interpolation(param: &ParamDefinition) -> AnimationInterpolation { + let layout = get_param_channel_layout(param); + layout + .components() + .first() + .map(|component| component.default_interpolation) + .unwrap_or(AnimationInterpolation::Linear) +} + +pub fn get_param_numeric_range(param: &ParamDefinition) -> Option<NumericSpec> { + let ParamDefinition::Number(param) = param else { + return None; + }; + Some(NumericSpec { + min: Some(param.min), + max: param.max, + step: Some(param.step), + }) +} + +fn fraction_digits_for_step(step: f64) -> usize { + if step != 0.0 && step.abs() < 1e-6 { + let exponential = format!("{step:e}"); + if let Some((_, exponent)) = exponential.split_once('e') { + if let Ok(value) = exponent.parse::<i32>() { + return value.unsigned_abs() as usize; + } + } + return 0; + } + let normalized = format!("{step}"); + normalized + .split_once('.') + .map(|(_, fraction)| fraction.len()) + .unwrap_or(0) +} + +pub fn snap_to_step(value: f64, step: f64) -> f64 { + if step <= 0.0 { + return value; + } + let snapped = (value / step).round() * step; + let digits = fraction_digits_for_step(step); + format!("{snapped:.digits$}").parse().unwrap_or(snapped) +} + +pub fn coerce_param_value(param: &ParamDefinition, value: &ParamValue) -> Option<ParamValue> { + match param { + ParamDefinition::Number(param) => { + let ParamValue::Number(number) = value else { + return None; + }; + if number.is_nan() { + return None; + } + let stepped = snap_to_step(*number, param.step); + let max = param.max.unwrap_or(f64::INFINITY); + Some(ParamValue::Number(stepped.min(max).max(param.min))) + } + ParamDefinition::Boolean(_) => match value { + ParamValue::Bool(value) => Some(ParamValue::Bool(*value)), + _ => None, + }, + ParamDefinition::Color(_) | ParamDefinition::Text(_) | ParamDefinition::Font(_) => { + match value { + ParamValue::String(value) => Some(ParamValue::String(value.clone())), + _ => None, + } + } + ParamDefinition::Select(param) => match value { + ParamValue::String(value) + if param.options.iter().any(|option| &option.value == value) => + { + Some(ParamValue::String(value.clone())) + } + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn intensity_param() -> ParamDefinition { + ParamDefinition::number("intensity", "Intensity", 0.0, 0.0, Some(1.0), 0.25) + } + + fn blend_param() -> ParamDefinition { + ParamDefinition::select( + "blend", + "Blend", + "normal", + vec![ + SelectOption { + value: "normal".to_string(), + label: "Normal".to_string(), + }, + SelectOption { + value: "multiply".to_string(), + label: "Multiply".to_string(), + }, + ], + ) + } + + #[test] + fn snaps_and_clamps_number_params() { + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(0.62)), + Some(ParamValue::Number(0.5)) + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(1.2)), + Some(ParamValue::Number(1.0)) + ); + } + + #[test] + fn rejects_nan_and_non_number_values() { + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(f64::NAN)), + None + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::String("0.5".to_string())), + None + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Bool(true)), + None + ); + } + + #[test] + fn passthrough_with_step_zero_guard() { + let param = ParamDefinition::number("x", "X", 0.0, 0.0, None, 0.0); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Number(0.123)), + Some(ParamValue::Number(0.123)) + ); + } + + #[test] + fn accepts_valid_select_values() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("normal".to_string())), + Some(ParamValue::String("normal".to_string())) + ); + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("multiply".to_string())), + Some(ParamValue::String("multiply".to_string())) + ); + } + + #[test] + fn rejects_select_values_outside_options() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("screen".to_string())), + None + ); + } + + #[test] + fn rejects_non_string_select_values() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::Number(42.0)), + None + ); + assert_eq!(coerce_param_value(&blend_param(), &ParamValue::Bool(true)), None); + } + + #[test] + fn boolean_params_accept_booleans_and_reject_other_types() { + let param = ParamDefinition::boolean("visible", "Visible", true); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Bool(true)), + Some(ParamValue::Bool(true)) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Bool(false)), + Some(ParamValue::Bool(false)) + ); + assert_eq!(coerce_param_value(¶m, &ParamValue::Number(1.0)), None); + assert_eq!( + coerce_param_value(¶m, &ParamValue::String("true".to_string())), + None + ); + } + + #[test] + fn color_params_accept_strings_and_reject_other_types() { + let param = ParamDefinition::color("fill", "Fill", "#ffffff"); + assert_eq!( + coerce_param_value(¶m, &ParamValue::String("#ff0000".to_string())), + Some(ParamValue::String("#ff0000".to_string())) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Number(0xff0000 as f64)), + None + ); + assert_eq!(coerce_param_value(¶m, &ParamValue::Bool(true)), None); + } + + #[test] + fn value_kind_maps_param_type_to_binding_kind() { + let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0); + assert_eq!(get_param_value_kind(&number), ParamValueKind::Number); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!(get_param_value_kind(&color), ParamValueKind::Color); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!(get_param_value_kind(&boolean), ParamValueKind::Discrete); + assert_eq!(get_param_value_kind(&blend_param()), ParamValueKind::Discrete); + } + + #[test] + fn default_interpolation_is_linear_for_continuous_hold_for_discrete() { + let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0); + assert_eq!( + get_param_default_interpolation(&number), + AnimationInterpolation::Linear + ); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!( + get_param_default_interpolation(&color), + AnimationInterpolation::Linear + ); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!( + get_param_default_interpolation(&boolean), + AnimationInterpolation::Hold + ); + assert_eq!( + get_param_default_interpolation(&blend_param()), + AnimationInterpolation::Hold + ); + } + + #[test] + fn numeric_range_returns_spec_for_number_params_only() { + assert_eq!( + get_param_numeric_range(&intensity_param()), + Some(NumericSpec { + min: Some(0.0), + max: Some(1.0), + step: Some(0.25), + }) + ); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!(get_param_numeric_range(&color), None); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!(get_param_numeric_range(&boolean), None); + } +} diff --git a/rust/crates/animation/src/path.rs b/rust/crates/animation/src/path.rs new file mode 100644 index 00000000..56256c61 --- /dev/null +++ b/rust/crates/animation/src/path.rs @@ -0,0 +1,23 @@ +use crate::effect_param_channel::is_effect_param_path; +use crate::graphic_param_channel::is_graphic_param_path; +use crate::property_groups::is_animation_property_path; + +pub fn is_animation_path(property_path: &str) -> bool { + is_animation_property_path(property_path) + || is_graphic_param_path(property_path) + || is_effect_param_path(property_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_animation_paths() { + assert!(is_animation_path("opacity")); + assert!(is_animation_path("params.blur")); + assert!(is_animation_path("effects.glow.params.intensity")); + assert!(!is_animation_path("transform")); + assert!(!is_animation_path("bindings")); + } +} diff --git a/rust/crates/animation/src/property_groups.rs b/rust/crates/animation/src/property_groups.rs new file mode 100644 index 00000000..fd639905 --- /dev/null +++ b/rust/crates/animation/src/property_groups.rs @@ -0,0 +1,110 @@ +use scene::{ANIMATION_PROPERTY_PATHS, ElementAnimations}; +use time::MediaTime; + +use crate::keyframe_query::get_keyframe_at_time; + +pub const ANIMATION_PROPERTY_GROUPS: &[(&str, &[&str])] = &[( + "transform.scale", + &["transform.scaleX", "transform.scaleY"], +)]; + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupKeyframeRef { + pub property_path: &'static str, + pub keyframe_id: String, +} + +pub fn animation_property_group_paths(group: &str) -> Option<&'static [&'static str]> { + ANIMATION_PROPERTY_GROUPS + .iter() + .find(|(name, _)| *name == group) + .map(|(_, paths)| *paths) +} + +pub fn get_group_keyframes_at_time( + animations: Option<&ElementAnimations>, + group: &str, + time: MediaTime, +) -> Vec<GroupKeyframeRef> { + let Some(paths) = animation_property_group_paths(group) else { + return Vec::new(); + }; + paths + .iter() + .filter_map(|property_path| { + get_keyframe_at_time(animations, property_path, time).map(|keyframe| { + GroupKeyframeRef { + property_path, + keyframe_id: keyframe.id, + } + }) + }) + .collect() +} + +pub fn has_group_keyframe_at_time( + animations: Option<&ElementAnimations>, + group: &str, + time: MediaTime, +) -> bool { + !get_group_keyframes_at_time(animations, group, time).is_empty() +} + +pub fn is_animation_property_path(property_path: &str) -> bool { + ANIMATION_PROPERTY_PATHS.contains(&property_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{ + AnimationChannel, ChannelData, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, + TangentMode, + }; + use std::collections::BTreeMap; + + #[test] + fn finds_group_keyframes_across_group_paths() { + let key = |id: &str| ScalarAnimationKey { + id: id.to_string(), + time: MediaTime::from_ticks(0), + value: 1.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }; + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert( + "transform.scaleX".to_string(), + ChannelData::Single(AnimationChannel::Scalar(ScalarChannel { + keys: vec![key("kx")], + extrapolation: None, + })), + ); + let refs = get_group_keyframes_at_time( + Some(&animations), + "transform.scale", + MediaTime::from_ticks(0), + ); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].property_path, "transform.scaleX"); + assert!(has_group_keyframe_at_time( + Some(&animations), + "transform.scale", + MediaTime::from_ticks(0) + )); + assert!(!has_group_keyframe_at_time( + Some(&animations), + "transform.scale", + MediaTime::from_ticks(10) + )); + } + + #[test] + fn recognizes_animation_property_paths() { + assert!(is_animation_property_path("opacity")); + assert!(is_animation_property_path("transform.rotate")); + assert!(!is_animation_property_path("params.blur")); + } +} diff --git a/rust/crates/animation/src/resolve.rs b/rust/crates/animation/src/resolve.rs new file mode 100644 index 00000000..ccb9dbad --- /dev/null +++ b/rust/crates/animation/src/resolve.rs @@ -0,0 +1,182 @@ +use scene::{ChannelData, ElementAnimations, ParamValue}; +use time::MediaTime; + +use crate::color::{LinearRgba, format_linear_rgba, parse_color_to_linear_rgba}; +use crate::interpolation::channel_value_at_time; + +pub fn get_element_local_time( + timeline_time: MediaTime, + element_start_time: MediaTime, + element_duration: MediaTime, +) -> MediaTime { + let local_time = timeline_time - element_start_time; + if local_time <= MediaTime::ZERO { + return MediaTime::ZERO; + } + if local_time >= element_duration { + return element_duration; + } + local_time +} + +pub fn resolve_animation_path_value_at_time( + animations: Option<&ElementAnimations>, + property_path: &str, + local_time: MediaTime, + fallback_value: &ParamValue, +) -> ParamValue { + let Some(data) = animations.and_then(|a| a.get(property_path)) else { + return fallback_value.clone(); + }; + + match data { + ChannelData::Single(channel) => match channel_value_at_time( + Some(channel), + local_time, + fallback_value, + ) { + value => value, + }, + ChannelData::Composite(components) => { + let ParamValue::String(fallback_color) = fallback_value else { + return fallback_value.clone(); + }; + let (Some(r), Some(g), Some(b), Some(a)) = ( + components.get("r"), + components.get("g"), + components.get("b"), + components.get("a"), + ) else { + return fallback_value.clone(); + }; + let Some(fallback_components) = parse_color_to_linear_rgba(fallback_color) else { + return fallback_value.clone(); + }; + + let component_at = |channel: &scene::AnimationChannel, fallback: f64| { + match channel_value_at_time( + Some(channel), + local_time, + &ParamValue::Number(fallback), + ) { + ParamValue::Number(value) => value, + _ => fallback, + } + }; + ParamValue::String(format_linear_rgba(&LinearRgba { + r: component_at(r, fallback_components.r), + g: component_at(g, fallback_components.g), + b: component_at(b, fallback_components.b), + a: component_at(a, fallback_components.a), + })) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{AnimationChannel, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + + fn at(time: i64) -> MediaTime { + MediaTime::from_ticks(time) + } + + #[test] + fn local_time_clamps_to_element_range() { + assert_eq!(get_element_local_time(at(50), at(100), at(200)), at(0)); + assert_eq!(get_element_local_time(at(150), at(100), at(200)), at(50)); + assert_eq!(get_element_local_time(at(400), at(100), at(200)), at(200)); + } + + #[test] + fn resolves_scalar_path_value() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![ + ScalarAnimationKey { + id: "a".to_string(), + time: at(0), + value: 0.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }, + ScalarAnimationKey { + id: "b".to_string(), + time: at(100), + value: 1.0, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }, + ], + extrapolation: None, + }); + let mut animations = BTreeMap::new(); + animations.insert("opacity".to_string(), ChannelData::Single(channel)); + assert_eq!( + resolve_animation_path_value_at_time( + Some(&animations), + "opacity", + at(50), + &ParamValue::Number(1.0), + ), + ParamValue::Number(0.5) + ); + assert_eq!( + resolve_animation_path_value_at_time( + Some(&animations), + "volume", + at(50), + &ParamValue::Number(1.0), + ), + ParamValue::Number(1.0) + ); + } + + #[test] + fn resolves_composite_color_path_value() { + let component = |value: f64| { + AnimationChannel::Scalar(ScalarChannel { + keys: vec![ScalarAnimationKey { + id: "k".to_string(), + time: at(0), + value, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }], + extrapolation: None, + }) + }; + let mut components = BTreeMap::new(); + components.insert("r".to_string(), component(1.0)); + components.insert("g".to_string(), component(0.0)); + components.insert("b".to_string(), component(0.0)); + components.insert("a".to_string(), component(1.0)); + let mut animations = BTreeMap::new(); + animations.insert("color".to_string(), ChannelData::Composite(components)); + assert_eq!( + resolve_animation_path_value_at_time( + Some(&animations), + "color", + at(0), + &ParamValue::String("#ffffff".to_string()), + ), + ParamValue::String("#ff0000".to_string()) + ); + assert_eq!( + resolve_animation_path_value_at_time( + Some(&animations), + "color", + at(0), + &ParamValue::Number(0.0), + ), + ParamValue::Number(0.0) + ); + } +} diff --git a/rust/crates/animation/src/values.rs b/rust/crates/animation/src/values.rs new file mode 100644 index 00000000..d16bf16a --- /dev/null +++ b/rust/crates/animation/src/values.rs @@ -0,0 +1,89 @@ +use scene::ElementAnimations; +use time::MediaTime; + +use crate::resolve::resolve_animation_path_value_at_time; +use scene::ParamValue; + +pub fn resolve_opacity_at_time( + base_opacity: f64, + animations: Option<&ElementAnimations>, + local_time: MediaTime, +) -> f64 { + match resolve_animation_path_value_at_time( + animations, + "opacity", + local_time.max(MediaTime::ZERO), + &ParamValue::Number(base_opacity), + ) { + ParamValue::Number(value) => value, + _ => base_opacity, + } +} + +pub fn resolve_number_at_time( + base_value: f64, + animations: Option<&ElementAnimations>, + property_path: &str, + local_time: MediaTime, +) -> f64 { + match resolve_animation_path_value_at_time( + animations, + property_path, + local_time.max(MediaTime::ZERO), + &ParamValue::Number(base_value), + ) { + ParamValue::Number(value) => value, + _ => base_value, + } +} + +pub fn resolve_color_at_time( + base_color: &str, + animations: Option<&ElementAnimations>, + property_path: &str, + local_time: MediaTime, +) -> String { + match resolve_animation_path_value_at_time( + animations, + property_path, + local_time.max(MediaTime::ZERO), + &ParamValue::String(base_color.to_string()), + ) { + ParamValue::String(value) => value, + _ => base_color.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use scene::{AnimationChannel, ChannelData, ScalarAnimationKey, ScalarChannel, ScalarSegmentType, TangentMode}; + use std::collections::BTreeMap; + + #[test] + fn resolves_opacity_with_animation_and_fallback() { + let channel = AnimationChannel::Scalar(ScalarChannel { + keys: vec![ScalarAnimationKey { + id: "a".to_string(), + time: MediaTime::from_ticks(0), + value: 0.25, + left_handle: None, + right_handle: None, + segment_to_next: ScalarSegmentType::Linear, + tangent_mode: TangentMode::Flat, + }], + extrapolation: None, + }); + let mut animations: ElementAnimations = BTreeMap::new(); + animations.insert("opacity".to_string(), ChannelData::Single(channel)); + assert_eq!( + resolve_opacity_at_time(1.0, Some(&animations), MediaTime::from_ticks(0)), + 0.25 + ); + assert_eq!( + resolve_opacity_at_time(1.0, Some(&animations), MediaTime::from_ticks(-5)), + 0.25 + ); + assert_eq!(resolve_opacity_at_time(1.0, None, MediaTime::from_ticks(0)), 1.0); + } +} |
