Skip to main content

oxihuman_morph/
emotion_timeline.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Keyframe-based emotion animation with interpolation and blending.
5
6use std::collections::HashMap;
7
8// ── Types ────────────────────────────────────────────────────────────────────
9
10/// A single keyframe in an emotion timeline.
11#[allow(dead_code)]
12pub struct EmotionKeyframe {
13    /// Time in seconds.
14    pub time: f32,
15    /// Map of emotion name to weight in 0..=1.
16    pub emotions: HashMap<String, f32>,
17    /// Easing function applied to interpolation leaving this keyframe.
18    pub easing: TimelineEasing,
19}
20
21/// Easing curve applied between two keyframes.
22#[allow(dead_code)]
23#[derive(Clone, Debug, PartialEq)]
24pub enum TimelineEasing {
25    Linear,
26    EaseIn,
27    EaseOut,
28    EaseInOut,
29    /// Jump to the *start* keyframe value; no interpolation.
30    Step,
31}
32
33/// Loop behaviour once the timeline reaches its end.
34#[allow(dead_code)]
35#[derive(Clone, Debug, PartialEq)]
36pub enum TimelineLoop {
37    /// Play once then hold the last frame.
38    Once,
39    /// Wrap back to the beginning.
40    Loop,
41    /// Bounce back and forth.
42    PingPong,
43}
44
45/// A keyframe-driven timeline of emotion weights.
46#[allow(dead_code)]
47pub struct EmotionTimeline {
48    /// Keyframes, always kept sorted by `time`.
49    pub keyframes: Vec<EmotionKeyframe>,
50    /// Total duration in seconds.
51    pub duration: f32,
52    /// How time is mapped past `duration`.
53    pub loop_mode: TimelineLoop,
54}
55
56// ── Implementations ──────────────────────────────────────────────────────────
57
58impl EmotionTimeline {
59    /// Create an empty timeline.
60    #[allow(dead_code)]
61    pub fn new(duration: f32, loop_mode: TimelineLoop) -> Self {
62        Self {
63            keyframes: Vec::new(),
64            duration,
65            loop_mode,
66        }
67    }
68
69    /// Insert a keyframe, keeping `keyframes` sorted by time.
70    #[allow(dead_code)]
71    pub fn add_keyframe(&mut self, kf: EmotionKeyframe) {
72        let pos = self
73            .keyframes
74            .partition_point(|existing| existing.time <= kf.time);
75        self.keyframes.insert(pos, kf);
76    }
77
78    /// Sample the emotion weights at an arbitrary time `t`.
79    #[allow(dead_code)]
80    pub fn sample(&self, t: f32) -> HashMap<String, f32> {
81        let t = normalize_emotion_time(t, self.duration, &self.loop_mode);
82
83        if self.keyframes.is_empty() {
84            return HashMap::new();
85        }
86
87        // Find surrounding keyframes.
88        let idx = self.keyframes.partition_point(|kf| kf.time <= t);
89
90        if idx == 0 {
91            // Before or at first keyframe – hold first.
92            return self.keyframes[0].emotions.clone();
93        }
94        if idx >= self.keyframes.len() {
95            // After last keyframe – hold last.
96            return self.keyframes[self.keyframes.len() - 1].emotions.clone();
97        }
98
99        let kf_a = &self.keyframes[idx - 1];
100        let kf_b = &self.keyframes[idx];
101
102        let span = kf_b.time - kf_a.time;
103        let raw_t = if span.abs() < f32::EPSILON {
104            1.0_f32
105        } else {
106            (t - kf_a.time) / span
107        };
108
109        let eased_t = apply_easing_fn(raw_t, &kf_a.easing);
110        interpolate_emotions(&kf_a.emotions, &kf_b.emotions, eased_t)
111    }
112
113    /// Bake the timeline to a uniform frame sequence at `fps` frames per second.
114    /// The returned vec has `ceil(duration * fps) + 1` entries (inclusive of t=0 and t=duration).
115    #[allow(dead_code)]
116    pub fn bake(&self, fps: f32) -> Vec<HashMap<String, f32>> {
117        let frame_count = (self.duration * fps).ceil() as usize + 1;
118        (0..frame_count)
119            .map(|i| {
120                let t = (i as f32) / fps;
121                self.sample(t)
122            })
123            .collect()
124    }
125}
126
127// ── Free functions ────────────────────────────────────────────────────────────
128
129/// Linear-interpolate two emotion weight maps, taking the union of their keys.
130/// Missing keys are treated as weight 0.0.
131#[allow(dead_code)]
132pub fn interpolate_emotions(
133    a: &HashMap<String, f32>,
134    b: &HashMap<String, f32>,
135    t: f32,
136) -> HashMap<String, f32> {
137    let mut result = HashMap::new();
138
139    for (k, &va) in a {
140        let vb = b.get(k).copied().unwrap_or(0.0);
141        result.insert(k.clone(), va + (vb - va) * t);
142    }
143    for (k, &vb) in b {
144        if !result.contains_key(k) {
145            // Key only in b: a-side is 0.
146            result.insert(k.clone(), vb * t);
147        }
148    }
149    result
150}
151
152/// Apply an easing curve to a normalised `t` in 0..=1.
153#[allow(dead_code)]
154pub fn apply_easing_fn(t: f32, easing: &TimelineEasing) -> f32 {
155    let t = t.clamp(0.0, 1.0);
156    match easing {
157        TimelineEasing::Linear => t,
158        TimelineEasing::EaseIn => t * t,
159        TimelineEasing::EaseOut => t * (2.0 - t),
160        TimelineEasing::EaseInOut => {
161            if t < 0.5 {
162                2.0 * t * t
163            } else {
164                -1.0 + (4.0 - 2.0 * t) * t
165            }
166        }
167        // Step: return 0 until the end of the interval, then jump.
168        TimelineEasing::Step => {
169            if t < 1.0 {
170                0.0
171            } else {
172                1.0
173            }
174        }
175    }
176}
177
178/// Map an arbitrary time `t` into `0..=duration` respecting the loop mode.
179#[allow(dead_code)]
180pub fn normalize_emotion_time(t: f32, duration: f32, loop_mode: &TimelineLoop) -> f32 {
181    if duration <= 0.0 {
182        return 0.0;
183    }
184    match loop_mode {
185        TimelineLoop::Once => t.clamp(0.0, duration),
186        TimelineLoop::Loop => {
187            let wrapped = t % duration;
188            if wrapped < 0.0 {
189                wrapped + duration
190            } else {
191                wrapped
192            }
193        }
194        TimelineLoop::PingPong => {
195            let period = 2.0 * duration;
196            let wrapped = ((t % period) + period) % period;
197            if wrapped <= duration {
198                wrapped
199            } else {
200                period - wrapped
201            }
202        }
203    }
204}
205
206// ── Tests ─────────────────────────────────────────────────────────────────────
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn make_kf(time: f32, emotions: &[(&str, f32)]) -> EmotionKeyframe {
212        EmotionKeyframe {
213            time,
214            emotions: emotions.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
215            easing: TimelineEasing::Linear,
216        }
217    }
218
219    fn make_kf_easing(
220        time: f32,
221        emotions: &[(&str, f32)],
222        easing: TimelineEasing,
223    ) -> EmotionKeyframe {
224        EmotionKeyframe {
225            time,
226            emotions: emotions.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
227            easing,
228        }
229    }
230
231    // 1. add_keyframe keeps list sorted
232    #[test]
233    fn test_add_keyframe_sorted() {
234        let mut tl = EmotionTimeline::new(2.0, TimelineLoop::Once);
235        tl.add_keyframe(make_kf(1.5, &[]));
236        tl.add_keyframe(make_kf(0.5, &[]));
237        tl.add_keyframe(make_kf(1.0, &[]));
238        let times: Vec<f32> = tl.keyframes.iter().map(|k| k.time).collect();
239        assert_eq!(times, vec![0.5, 1.0, 1.5]);
240    }
241
242    // 2. sample at exact keyframe time returns that keyframe's values
243    #[test]
244    fn test_sample_exact_keyframe() {
245        let mut tl = EmotionTimeline::new(2.0, TimelineLoop::Once);
246        tl.add_keyframe(make_kf(0.0, &[("happy", 0.0)]));
247        tl.add_keyframe(make_kf(1.0, &[("happy", 1.0)]));
248        let result = tl.sample(0.0);
249        assert!((result["happy"] - 0.0).abs() < 1e-5);
250        let result2 = tl.sample(1.0);
251        assert!((result2["happy"] - 1.0).abs() < 1e-5);
252    }
253
254    // 3. sample between two keyframes interpolates correctly
255    #[test]
256    fn test_sample_between_keyframes() {
257        let mut tl = EmotionTimeline::new(2.0, TimelineLoop::Once);
258        tl.add_keyframe(make_kf(0.0, &[("happy", 0.0)]));
259        tl.add_keyframe(make_kf(2.0, &[("happy", 1.0)]));
260        let result = tl.sample(1.0);
261        assert!((result["happy"] - 0.5).abs() < 1e-5);
262    }
263
264    // 4. sample past end clamps to last keyframe (Once mode)
265    #[test]
266    fn test_sample_past_end_clamps() {
267        let mut tl = EmotionTimeline::new(1.0, TimelineLoop::Once);
268        tl.add_keyframe(make_kf(0.0, &[("sad", 0.0)]));
269        tl.add_keyframe(make_kf(1.0, &[("sad", 0.8)]));
270        let result = tl.sample(99.0);
271        assert!((result["sad"] - 0.8).abs() < 1e-5);
272    }
273
274    // 5. Loop mode wraps time back to start
275    #[test]
276    fn test_loop_wraps() {
277        let mut tl = EmotionTimeline::new(1.0, TimelineLoop::Loop);
278        tl.add_keyframe(make_kf(0.0, &[("anger", 1.0)]));
279        tl.add_keyframe(make_kf(1.0, &[("anger", 0.0)]));
280        // t=1.25 should wrap to t=0.25 => anger=0.75
281        let result = tl.sample(1.25);
282        assert!((result["anger"] - 0.75).abs() < 1e-4);
283    }
284
285    // 6. PingPong mode bounces
286    #[test]
287    fn test_ping_pong() {
288        let mut tl = EmotionTimeline::new(1.0, TimelineLoop::PingPong);
289        tl.add_keyframe(make_kf(0.0, &[("joy", 0.0)]));
290        tl.add_keyframe(make_kf(1.0, &[("joy", 1.0)]));
291        // t=1.5 → ping-pong → t=0.5 → joy=0.5
292        let result = tl.sample(1.5);
293        assert!((result["joy"] - 0.5).abs() < 1e-4);
294    }
295
296    // 7. bake frame count = ceil(duration*fps)+1
297    #[test]
298    fn test_bake_frame_count() {
299        let mut tl = EmotionTimeline::new(2.0, TimelineLoop::Once);
300        tl.add_keyframe(make_kf(0.0, &[("e", 0.0)]));
301        tl.add_keyframe(make_kf(2.0, &[("e", 1.0)]));
302        let frames = tl.bake(30.0);
303        let expected = (2.0_f32 * 30.0).ceil() as usize + 1; // 61
304        assert_eq!(frames.len(), expected);
305    }
306
307    // 8. interpolate_emotions merges keys from both maps
308    #[test]
309    fn test_interpolate_emotions_merges_keys() {
310        let a: HashMap<String, f32> = [("happy".to_string(), 1.0)].into();
311        let b: HashMap<String, f32> = [("sad".to_string(), 1.0)].into();
312        let result = interpolate_emotions(&a, &b, 0.5);
313        assert!((result["happy"] - 0.5).abs() < 1e-5);
314        assert!((result["sad"] - 0.5).abs() < 1e-5);
315    }
316
317    // 9. apply_easing_fn Linear is identity
318    #[test]
319    fn test_easing_linear_identity() {
320        for &v in &[0.0_f32, 0.25, 0.5, 0.75, 1.0] {
321            assert!((apply_easing_fn(v, &TimelineEasing::Linear) - v).abs() < 1e-6);
322        }
323    }
324
325    // 10. apply_easing_fn EaseInOut midpoint ≈ 0.5
326    #[test]
327    fn test_easing_ease_in_out_midpoint() {
328        let v = apply_easing_fn(0.5, &TimelineEasing::EaseInOut);
329        assert!((v - 0.5).abs() < 1e-5);
330    }
331
332    // 11. Step easing returns 0 for t<1 and 1 at t=1
333    #[test]
334    fn test_easing_step_returns_prior() {
335        assert!((apply_easing_fn(0.0, &TimelineEasing::Step) - 0.0).abs() < 1e-6);
336        assert!((apply_easing_fn(0.5, &TimelineEasing::Step) - 0.0).abs() < 1e-6);
337        assert!((apply_easing_fn(0.999, &TimelineEasing::Step) - 0.0).abs() < 1e-6);
338        assert!((apply_easing_fn(1.0, &TimelineEasing::Step) - 1.0).abs() < 1e-6);
339    }
340
341    // 12. sample with Step easing holds prior value throughout interval
342    #[test]
343    fn test_step_easing_holds_prior_value() {
344        let mut tl = EmotionTimeline::new(2.0, TimelineLoop::Once);
345        tl.add_keyframe(make_kf_easing(0.0, &[("fear", 0.2)], TimelineEasing::Step));
346        tl.add_keyframe(make_kf_easing(1.0, &[("fear", 0.8)], TimelineEasing::Step));
347        tl.add_keyframe(make_kf_easing(2.0, &[("fear", 0.4)], TimelineEasing::Step));
348        // Between 0.0 and 1.0, step easing should hold 0.2 (start value).
349        let mid = tl.sample(0.5);
350        assert!((mid["fear"] - 0.2).abs() < 1e-5);
351    }
352}