summaryrefslogtreecommitdiff
path: root/rust/crates/animation
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/animation
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/animation')
-rw-r--r--rust/crates/animation/src/bezier.rs6
-rw-r--r--rust/crates/animation/src/color.rs5
-rw-r--r--rust/crates/animation/src/effect_param_channel.rs125
-rw-r--r--rust/crates/animation/src/graph_channels.rs184
-rw-r--r--rust/crates/animation/src/graphic_param_channel.rs102
-rw-r--r--rust/crates/animation/src/interpolation.rs11
-rw-r--r--rust/crates/animation/src/keyframe_query.rs308
-rw-r--r--rust/crates/animation/src/keyframes.rs363
-rw-r--r--rust/crates/animation/src/lib.rs74
-rw-r--r--rust/crates/animation/src/params.rs445
-rw-r--r--rust/crates/animation/src/path.rs23
-rw-r--r--rust/crates/animation/src/property_groups.rs110
-rw-r--r--rust/crates/animation/src/resolve.rs182
-rw-r--r--rust/crates/animation/src/values.rs89
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",
+ &params,
+ 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(
+ &params,
+ &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) => &param.key,
+ ParamDefinition::Boolean(param) => &param.key,
+ ParamDefinition::Color(param) => &param.key,
+ ParamDefinition::Select(param) => &param.key,
+ ParamDefinition::Text(param) => &param.key,
+ ParamDefinition::Font(param) => &param.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(&param, &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(&param, &ParamValue::Bool(true)),
+ Some(ParamValue::Bool(true))
+ );
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Bool(false)),
+ Some(ParamValue::Bool(false))
+ );
+ assert_eq!(coerce_param_value(&param, &ParamValue::Number(1.0)), None);
+ assert_eq!(
+ coerce_param_value(&param, &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(&param, &ParamValue::String("#ff0000".to_string())),
+ Some(ParamValue::String("#ff0000".to_string()))
+ );
+ assert_eq!(
+ coerce_param_value(&param, &ParamValue::Number(0xff0000 as f64)),
+ None
+ );
+ assert_eq!(coerce_param_value(&param, &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);
+ }
+}