Skip to main content

oxihuman_morph/
expression_sequence.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6//! Named expression keyframe sequence: compose multi-expression animations
7//! with hold/ease timing.
8
9use std::collections::HashMap;
10
11// ---------------------------------------------------------------------------
12// Core types
13// ---------------------------------------------------------------------------
14
15/// Map of expression name → weight in \[0, 1\].
16pub type ExprWeights = HashMap<String, f32>;
17
18/// Easing function applied when transitioning to the next keyframe.
19#[derive(Debug, Clone, PartialEq)]
20pub enum EaseType {
21    /// Constant velocity – no acceleration.
22    Linear,
23    /// Accelerate from zero.
24    EaseIn,
25    /// Decelerate to zero.
26    EaseOut,
27    /// Smooth S-curve acceleration and deceleration.
28    EaseInOut,
29    /// Jump instantly to the next keyframe value (hold until next).
30    Step,
31}
32
33/// A single snapshot in an expression animation track.
34pub struct ExprKeyframe {
35    /// Time in seconds at which this keyframe is reached.
36    pub time: f32,
37    /// Expression weights active at this instant.
38    pub weights: ExprWeights,
39    /// Easing applied when blending toward the following keyframe.
40    pub ease_to_next: EaseType,
41    /// Seconds to hold at this keyframe before starting the transition.
42    pub hold_duration: f32,
43}
44
45/// How the track behaves after the last keyframe has been reached.
46#[derive(Debug, Clone, PartialEq)]
47pub enum SeqLoopMode {
48    /// Play once and freeze on the last keyframe.
49    Once,
50    /// Wrap time back to the beginning and repeat.
51    Loop,
52    /// Bounce back and forth between first and last keyframe.
53    PingPong,
54}
55
56/// A named sequence of [`ExprKeyframe`]s.
57pub struct ExprTrack {
58    /// Human-readable name for this track (e.g. `"happy_cycle"`).
59    pub name: String,
60    /// Keyframes sorted ascending by `time`.
61    pub keyframes: Vec<ExprKeyframe>,
62    /// Looping behaviour.
63    pub loop_mode: SeqLoopMode,
64}
65
66/// Combines multiple [`ExprTrack`]s and evaluates them additively.
67pub struct ExprSequencer {
68    tracks: Vec<ExprTrack>,
69}
70
71// ---------------------------------------------------------------------------
72// Standalone functions
73// ---------------------------------------------------------------------------
74
75/// Apply an easing function to `t` ∈ \[0, 1\].
76///
77/// The result is also ∈ \[0, 1\].
78pub fn ease_value(t: f32, ease: &EaseType) -> f32 {
79    let t = t.clamp(0.0, 1.0);
80    match ease {
81        EaseType::Linear => t,
82        EaseType::EaseIn => t * t,
83        EaseType::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
84        EaseType::EaseInOut => t * t * (3.0 - 2.0 * t),
85        EaseType::Step => {
86            if t < 1.0 {
87                0.0
88            } else {
89                1.0
90            }
91        }
92    }
93}
94
95/// Linearly interpolate between two [`ExprWeights`] maps.
96///
97/// Keys present in only one map are treated as having weight `0` in the other.
98pub fn lerp_weights(a: &ExprWeights, b: &ExprWeights, t: f32) -> ExprWeights {
99    let t = t.clamp(0.0, 1.0);
100    let mut out: ExprWeights = HashMap::new();
101
102    for (k, va) in a {
103        let vb = b.get(k).copied().unwrap_or(0.0);
104        out.insert(k.clone(), va + (vb - va) * t);
105    }
106    for (k, vb) in b {
107        if !out.contains_key(k) {
108            out.insert(k.clone(), vb * t);
109        }
110    }
111    out
112}
113
114/// Generate a repeating blink animation track.
115///
116/// * `interval` – seconds between blink starts.
117/// * `duration` – total duration of one blink (open → close → open).
118pub fn blink_track(interval: f32, duration: f32) -> ExprTrack {
119    let interval = interval.max(duration + 0.01);
120    let close_t = duration * 0.3;
121    let open_t = duration;
122
123    let closed: ExprWeights = [("blink".to_string(), 1.0)].into();
124    let open: ExprWeights = [("blink".to_string(), 0.0)].into();
125
126    let mut track = ExprTrack::new("blink");
127    track.loop_mode = SeqLoopMode::Loop;
128
129    // Start open, close quickly, hold shut briefly, reopen.
130    track.add_keyframe(ExprKeyframe {
131        time: 0.0,
132        weights: open.clone(),
133        ease_to_next: EaseType::EaseIn,
134        hold_duration: interval - duration,
135    });
136    track.add_keyframe(ExprKeyframe {
137        time: interval - duration,
138        weights: open.clone(),
139        ease_to_next: EaseType::EaseIn,
140        hold_duration: 0.0,
141    });
142    track.add_keyframe(ExprKeyframe {
143        time: interval - duration + close_t,
144        weights: closed,
145        ease_to_next: EaseType::EaseOut,
146        hold_duration: 0.0,
147    });
148    track.add_keyframe(ExprKeyframe {
149        time: interval - duration + open_t,
150        weights: open,
151        ease_to_next: EaseType::Linear,
152        hold_duration: 0.0,
153    });
154
155    track
156}
157
158/// Generate a subtle breathing expression track.
159///
160/// `rate` is breaths per minute.  The track loops indefinitely.
161pub fn breathing_expr_track(rate: f32) -> ExprTrack {
162    let period = 60.0 / rate.max(1.0);
163    let inhale_end = period * 0.4;
164    let exhale_end = period;
165
166    let inhale: ExprWeights = [
167        ("nostrils_flare".to_string(), 0.3),
168        ("chest_expand".to_string(), 0.2),
169    ]
170    .into();
171    let exhale: ExprWeights = [
172        ("nostrils_flare".to_string(), 0.0),
173        ("chest_expand".to_string(), 0.0),
174    ]
175    .into();
176
177    let mut track = ExprTrack::new("breathing");
178    track.loop_mode = SeqLoopMode::Loop;
179
180    track.add_keyframe(ExprKeyframe {
181        time: 0.0,
182        weights: exhale.clone(),
183        ease_to_next: EaseType::EaseInOut,
184        hold_duration: 0.0,
185    });
186    track.add_keyframe(ExprKeyframe {
187        time: inhale_end,
188        weights: inhale,
189        ease_to_next: EaseType::EaseInOut,
190        hold_duration: 0.0,
191    });
192    track.add_keyframe(ExprKeyframe {
193        time: exhale_end,
194        weights: exhale,
195        ease_to_next: EaseType::EaseInOut,
196        hold_duration: 0.0,
197    });
198
199    track
200}
201
202// ---------------------------------------------------------------------------
203// ExprTrack impl
204// ---------------------------------------------------------------------------
205
206impl ExprTrack {
207    /// Create a new, empty track with the given name.
208    pub fn new(name: &str) -> Self {
209        Self {
210            name: name.to_string(),
211            keyframes: Vec::new(),
212            loop_mode: SeqLoopMode::Once,
213        }
214    }
215
216    /// Insert a keyframe, keeping the list sorted by `time`.
217    pub fn add_keyframe(&mut self, kf: ExprKeyframe) {
218        let pos = self.keyframes.partition_point(|k| k.time <= kf.time);
219        self.keyframes.insert(pos, kf);
220    }
221
222    /// Remove the keyframe at `idx` (no-op if out of bounds).
223    pub fn remove_keyframe(&mut self, idx: usize) {
224        if idx < self.keyframes.len() {
225            self.keyframes.remove(idx);
226        }
227    }
228
229    /// Total track duration: time of the last keyframe plus its hold.
230    pub fn duration(&self) -> f32 {
231        self.keyframes
232            .last()
233            .map(|kf| kf.time + kf.hold_duration)
234            .unwrap_or(0.0)
235    }
236
237    /// Evaluate expression weights at time `t_in`, respecting loop mode and easing.
238    pub fn evaluate(&self, t_in: f32) -> ExprWeights {
239        if self.keyframes.is_empty() {
240            return HashMap::new();
241        }
242        if self.keyframes.len() == 1 {
243            return self.keyframes[0].weights.clone();
244        }
245
246        let dur = self.duration();
247        let t = self.remap_time(t_in, dur);
248
249        // Find the pair of keyframes that straddle `t`.
250        let next_idx = self.keyframes.partition_point(|kf| kf.time <= t);
251
252        if next_idx == 0 {
253            return self.keyframes[0].weights.clone();
254        }
255        if next_idx >= self.keyframes.len() {
256            return self.keyframes[self.keyframes.len() - 1].weights.clone();
257        }
258
259        let prev = &self.keyframes[next_idx - 1];
260        let next = &self.keyframes[next_idx];
261
262        // Time after the hold period ends.
263        let transition_start = prev.time + prev.hold_duration;
264
265        // Still within the hold window.
266        if t <= transition_start {
267            return prev.weights.clone();
268        }
269
270        let span = next.time - transition_start;
271        let local_t = if span > f32::EPSILON {
272            ((t - transition_start) / span).clamp(0.0, 1.0)
273        } else {
274            1.0
275        };
276
277        let eased_t = ease_value(local_t, &prev.ease_to_next);
278        lerp_weights(&prev.weights, &next.weights, eased_t)
279    }
280
281    // -----------------------------------------------------------------------
282    // Private helpers
283    // -----------------------------------------------------------------------
284
285    fn remap_time(&self, t: f32, dur: f32) -> f32 {
286        if dur <= 0.0 {
287            return 0.0;
288        }
289        match self.loop_mode {
290            SeqLoopMode::Once => t.clamp(0.0, dur),
291            SeqLoopMode::Loop => {
292                let wrapped = t % dur;
293                if wrapped < 0.0 {
294                    wrapped + dur
295                } else {
296                    wrapped
297                }
298            }
299            SeqLoopMode::PingPong => {
300                let cycle = dur * 2.0;
301                let pos = t % cycle;
302                let pos = if pos < 0.0 { pos + cycle } else { pos };
303                if pos <= dur {
304                    pos
305                } else {
306                    cycle - pos
307                }
308            }
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// ExprSequencer impl
315// ---------------------------------------------------------------------------
316
317impl ExprSequencer {
318    /// Create an empty sequencer.
319    pub fn new() -> Self {
320        Self { tracks: Vec::new() }
321    }
322
323    /// Add a track to the sequencer.
324    pub fn add_track(&mut self, track: ExprTrack) {
325        self.tracks.push(track);
326    }
327
328    /// Number of tracks currently registered.
329    pub fn track_count(&self) -> usize {
330        self.tracks.len()
331    }
332
333    /// Evaluate all tracks at time `t` and blend additively.
334    ///
335    /// When the same expression key appears in multiple tracks the weights
336    /// are summed and then clamped to \[0, 1\].
337    pub fn evaluate_all(&self, t: f32) -> ExprWeights {
338        let mut out: ExprWeights = HashMap::new();
339        for track in &self.tracks {
340            let w = track.evaluate(t);
341            for (k, v) in w {
342                *out.entry(k).or_insert(0.0) += v;
343            }
344        }
345        // Clamp additive sum.
346        for v in out.values_mut() {
347            *v = v.clamp(0.0, 1.0);
348        }
349        out
350    }
351}
352
353impl Default for ExprSequencer {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn approx(a: f32, b: f32) -> bool {
368        (a - b).abs() < 1e-5
369    }
370
371    fn single_weight(name: &str, v: f32) -> ExprWeights {
372        [(name.to_string(), v)].into()
373    }
374
375    // --- ease_value ----------------------------------------------------------
376
377    #[test]
378    fn test_ease_linear() {
379        assert!(approx(ease_value(0.0, &EaseType::Linear), 0.0));
380        assert!(approx(ease_value(0.5, &EaseType::Linear), 0.5));
381        assert!(approx(ease_value(1.0, &EaseType::Linear), 1.0));
382    }
383
384    #[test]
385    fn test_ease_in() {
386        assert!(approx(ease_value(0.0, &EaseType::EaseIn), 0.0));
387        assert!(approx(ease_value(0.5, &EaseType::EaseIn), 0.25));
388        assert!(approx(ease_value(1.0, &EaseType::EaseIn), 1.0));
389    }
390
391    #[test]
392    fn test_ease_out() {
393        assert!(approx(ease_value(0.0, &EaseType::EaseOut), 0.0));
394        assert!(approx(ease_value(0.5, &EaseType::EaseOut), 0.75));
395        assert!(approx(ease_value(1.0, &EaseType::EaseOut), 1.0));
396    }
397
398    #[test]
399    fn test_ease_in_out_midpoint() {
400        // smoothstep(0.5) = 0.5
401        assert!(approx(ease_value(0.5, &EaseType::EaseInOut), 0.5));
402    }
403
404    #[test]
405    fn test_ease_step() {
406        assert!(approx(ease_value(0.0, &EaseType::Step), 0.0));
407        assert!(approx(ease_value(0.5, &EaseType::Step), 0.0));
408        assert!(approx(ease_value(1.0, &EaseType::Step), 1.0));
409    }
410
411    #[test]
412    fn test_ease_clamps_input() {
413        assert!(approx(ease_value(-1.0, &EaseType::Linear), 0.0));
414        assert!(approx(ease_value(2.0, &EaseType::Linear), 1.0));
415    }
416
417    // --- lerp_weights --------------------------------------------------------
418
419    #[test]
420    fn test_lerp_weights_midpoint() {
421        let a = single_weight("smile", 0.0);
422        let b = single_weight("smile", 1.0);
423        let mid = lerp_weights(&a, &b, 0.5);
424        assert!(approx(*mid.get("smile").expect("should succeed"), 0.5));
425    }
426
427    #[test]
428    fn test_lerp_weights_missing_key_in_b() {
429        let a = single_weight("anger", 0.8);
430        let b: ExprWeights = HashMap::new();
431        let result = lerp_weights(&a, &b, 0.5);
432        assert!(approx(*result.get("anger").expect("should succeed"), 0.4));
433    }
434
435    #[test]
436    fn test_lerp_weights_missing_key_in_a() {
437        let a: ExprWeights = HashMap::new();
438        let b = single_weight("joy", 1.0);
439        let result = lerp_weights(&a, &b, 0.5);
440        assert!(approx(*result.get("joy").expect("should succeed"), 0.5));
441    }
442
443    #[test]
444    fn test_lerp_weights_t0_equals_a() {
445        let a = single_weight("fear", 0.6);
446        let b = single_weight("fear", 0.0);
447        let result = lerp_weights(&a, &b, 0.0);
448        assert!(approx(*result.get("fear").expect("should succeed"), 0.6));
449    }
450
451    // --- ExprTrack -----------------------------------------------------------
452
453    #[test]
454    fn test_track_add_sorted() {
455        let mut track = ExprTrack::new("test");
456        track.add_keyframe(ExprKeyframe {
457            time: 2.0,
458            weights: single_weight("a", 1.0),
459            ease_to_next: EaseType::Linear,
460            hold_duration: 0.0,
461        });
462        track.add_keyframe(ExprKeyframe {
463            time: 0.0,
464            weights: single_weight("a", 0.0),
465            ease_to_next: EaseType::Linear,
466            hold_duration: 0.0,
467        });
468        assert!(approx(track.keyframes[0].time, 0.0));
469        assert!(approx(track.keyframes[1].time, 2.0));
470    }
471
472    #[test]
473    fn test_track_duration() {
474        let mut track = ExprTrack::new("dur_test");
475        track.add_keyframe(ExprKeyframe {
476            time: 3.0,
477            weights: HashMap::new(),
478            ease_to_next: EaseType::Linear,
479            hold_duration: 0.5,
480        });
481        assert!(approx(track.duration(), 3.5));
482    }
483
484    #[test]
485    fn test_track_remove_keyframe() {
486        let mut track = ExprTrack::new("rm");
487        track.add_keyframe(ExprKeyframe {
488            time: 0.0,
489            weights: HashMap::new(),
490            ease_to_next: EaseType::Linear,
491            hold_duration: 0.0,
492        });
493        track.add_keyframe(ExprKeyframe {
494            time: 1.0,
495            weights: HashMap::new(),
496            ease_to_next: EaseType::Linear,
497            hold_duration: 0.0,
498        });
499        track.remove_keyframe(0);
500        assert_eq!(track.keyframes.len(), 1);
501        assert!(approx(track.keyframes[0].time, 1.0));
502    }
503
504    #[test]
505    fn test_track_evaluate_at_start() {
506        let mut track = ExprTrack::new("eval");
507        track.add_keyframe(ExprKeyframe {
508            time: 0.0,
509            weights: single_weight("smile", 0.0),
510            ease_to_next: EaseType::Linear,
511            hold_duration: 0.0,
512        });
513        track.add_keyframe(ExprKeyframe {
514            time: 1.0,
515            weights: single_weight("smile", 1.0),
516            ease_to_next: EaseType::Linear,
517            hold_duration: 0.0,
518        });
519        let w = track.evaluate(0.0);
520        assert!(approx(*w.get("smile").expect("should succeed"), 0.0));
521    }
522
523    #[test]
524    fn test_track_evaluate_at_end() {
525        let mut track = ExprTrack::new("eval_end");
526        track.add_keyframe(ExprKeyframe {
527            time: 0.0,
528            weights: single_weight("smile", 0.0),
529            ease_to_next: EaseType::Linear,
530            hold_duration: 0.0,
531        });
532        track.add_keyframe(ExprKeyframe {
533            time: 1.0,
534            weights: single_weight("smile", 1.0),
535            ease_to_next: EaseType::Linear,
536            hold_duration: 0.0,
537        });
538        let w = track.evaluate(1.0);
539        assert!(approx(*w.get("smile").expect("should succeed"), 1.0));
540    }
541
542    #[test]
543    fn test_track_evaluate_midpoint_linear() {
544        let mut track = ExprTrack::new("mid_linear");
545        track.add_keyframe(ExprKeyframe {
546            time: 0.0,
547            weights: single_weight("brow", 0.0),
548            ease_to_next: EaseType::Linear,
549            hold_duration: 0.0,
550        });
551        track.add_keyframe(ExprKeyframe {
552            time: 2.0,
553            weights: single_weight("brow", 1.0),
554            ease_to_next: EaseType::Linear,
555            hold_duration: 0.0,
556        });
557        let w = track.evaluate(1.0);
558        assert!(approx(*w.get("brow").expect("should succeed"), 0.5));
559    }
560
561    #[test]
562    fn test_track_loop_mode() {
563        let mut track = ExprTrack::new("loop");
564        track.loop_mode = SeqLoopMode::Loop;
565        track.add_keyframe(ExprKeyframe {
566            time: 0.0,
567            weights: single_weight("x", 0.0),
568            ease_to_next: EaseType::Linear,
569            hold_duration: 0.0,
570        });
571        track.add_keyframe(ExprKeyframe {
572            time: 1.0,
573            weights: single_weight("x", 1.0),
574            ease_to_next: EaseType::Linear,
575            hold_duration: 0.0,
576        });
577        // At t=1.5 with loop (dur=1), maps to t=0.5 → 0.5
578        let w = track.evaluate(1.5);
579        assert!(approx(*w.get("x").expect("should succeed"), 0.5));
580    }
581
582    #[test]
583    fn test_track_pingpong_mode() {
584        let mut track = ExprTrack::new("pp");
585        track.loop_mode = SeqLoopMode::PingPong;
586        track.add_keyframe(ExprKeyframe {
587            time: 0.0,
588            weights: single_weight("y", 0.0),
589            ease_to_next: EaseType::Linear,
590            hold_duration: 0.0,
591        });
592        track.add_keyframe(ExprKeyframe {
593            time: 1.0,
594            weights: single_weight("y", 1.0),
595            ease_to_next: EaseType::Linear,
596            hold_duration: 0.0,
597        });
598        // PingPong: dur=1, cycle=2. At t=1.5, pos=1.5 > 1 → cycle-pos=0.5 → y=0.5
599        let w = track.evaluate(1.5);
600        assert!(approx(*w.get("y").expect("should succeed"), 0.5));
601    }
602
603    #[test]
604    fn test_track_hold_duration() {
605        let mut track = ExprTrack::new("hold");
606        track.add_keyframe(ExprKeyframe {
607            time: 0.0,
608            weights: single_weight("sad", 1.0),
609            ease_to_next: EaseType::Linear,
610            hold_duration: 1.0, // hold for 1 second before transitioning
611        });
612        track.add_keyframe(ExprKeyframe {
613            time: 2.0,
614            weights: single_weight("sad", 0.0),
615            ease_to_next: EaseType::Linear,
616            hold_duration: 0.0,
617        });
618        // At t=0.5 we should still be in the hold window → weight = 1.0
619        let w = track.evaluate(0.5);
620        assert!(approx(*w.get("sad").expect("should succeed"), 1.0));
621        // At t=1.5 transition is half-way (from t=1..t=2) → weight ≈ 0.5
622        let w2 = track.evaluate(1.5);
623        assert!(approx(*w2.get("sad").expect("should succeed"), 0.5));
624    }
625
626    // --- ExprSequencer -------------------------------------------------------
627
628    #[test]
629    fn test_sequencer_track_count() {
630        let mut seq = ExprSequencer::new();
631        seq.add_track(ExprTrack::new("a"));
632        seq.add_track(ExprTrack::new("b"));
633        assert_eq!(seq.track_count(), 2);
634    }
635
636    #[test]
637    fn test_sequencer_evaluate_all_additive_clamp() {
638        let mut seq = ExprSequencer::new();
639
640        let mut t1 = ExprTrack::new("t1");
641        t1.add_keyframe(ExprKeyframe {
642            time: 0.0,
643            weights: single_weight("smile", 0.8),
644            ease_to_next: EaseType::Linear,
645            hold_duration: 0.0,
646        });
647
648        let mut t2 = ExprTrack::new("t2");
649        t2.add_keyframe(ExprKeyframe {
650            time: 0.0,
651            weights: single_weight("smile", 0.6),
652            ease_to_next: EaseType::Linear,
653            hold_duration: 0.0,
654        });
655
656        seq.add_track(t1);
657        seq.add_track(t2);
658
659        let w = seq.evaluate_all(0.0);
660        // 0.8 + 0.6 = 1.4 → clamped to 1.0
661        assert!(approx(*w.get("smile").expect("should succeed"), 1.0));
662    }
663
664    // --- blink_track ---------------------------------------------------------
665
666    #[test]
667    fn test_blink_track_loops() {
668        let track = blink_track(4.0, 0.2);
669        assert_eq!(track.loop_mode, SeqLoopMode::Loop);
670    }
671
672    #[test]
673    fn test_blink_track_has_keyframes() {
674        let track = blink_track(4.0, 0.2);
675        assert!(track.keyframes.len() >= 3);
676    }
677
678    // --- breathing_expr_track ------------------------------------------------
679
680    #[test]
681    fn test_breathing_track_loops() {
682        let track = breathing_expr_track(15.0);
683        assert_eq!(track.loop_mode, SeqLoopMode::Loop);
684    }
685
686    #[test]
687    fn test_breathing_track_has_keyframes() {
688        let track = breathing_expr_track(15.0);
689        assert!(track.keyframes.len() >= 2);
690    }
691
692    #[test]
693    fn test_breathing_track_period() {
694        let rate = 12.0_f32;
695        let expected_period = 60.0 / rate;
696        let track = breathing_expr_track(rate);
697        assert!(approx(track.duration(), expected_period));
698    }
699}