Skip to main content

oxihuman_morph/
param_animation.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// InterpMode
10// ---------------------------------------------------------------------------
11
12/// Interpolation mode between keyframes.
13#[derive(Debug, Clone, PartialEq)]
14pub enum InterpMode {
15    /// Hold value until next keyframe.
16    Step,
17    /// Linear interpolation (lerp).
18    Linear,
19    /// Smoothstep: 3t^2 - 2t^3.
20    Smooth,
21    /// Cubic bezier with in/out tangents.
22    Bezier,
23    /// Sinusoidal ease.
24    Sine,
25}
26
27// ---------------------------------------------------------------------------
28// Keyframe
29// ---------------------------------------------------------------------------
30
31/// A single keyframe on an animation track.
32pub struct Keyframe {
33    /// Time in seconds.
34    pub time: f32,
35    /// Parameter value in [0..1].
36    pub value: f32,
37    /// Interpolation mode to use from this keyframe to the next.
38    pub interp: InterpMode,
39    /// Bezier in-tangent (used when `interp == InterpMode::Bezier`).
40    pub tan_in: f32,
41    /// Bezier out-tangent (used when `interp == InterpMode::Bezier`).
42    pub tan_out: f32,
43}
44
45impl Keyframe {
46    /// Create a keyframe with `Linear` interpolation.
47    pub fn new(time: f32, value: f32) -> Self {
48        Self {
49            time,
50            value,
51            interp: InterpMode::Linear,
52            tan_in: 0.0,
53            tan_out: 0.0,
54        }
55    }
56
57    /// Create a keyframe with `Step` interpolation.
58    pub fn step(time: f32, value: f32) -> Self {
59        Self {
60            time,
61            value,
62            interp: InterpMode::Step,
63            tan_in: 0.0,
64            tan_out: 0.0,
65        }
66    }
67
68    /// Create a keyframe with `Smooth` interpolation.
69    pub fn smooth(time: f32, value: f32) -> Self {
70        Self {
71            time,
72            value,
73            interp: InterpMode::Smooth,
74            tan_in: 0.0,
75            tan_out: 0.0,
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// LoopMode
82// ---------------------------------------------------------------------------
83
84/// How a track behaves when time is outside [first_kf, last_kf].
85#[derive(Debug, Clone, PartialEq)]
86pub enum LoopMode {
87    /// Hold the first or last value.
88    Clamp,
89    /// Wrap time back to the start.
90    Loop,
91    /// Alternate forward/backward.
92    PingPong,
93}
94
95// ---------------------------------------------------------------------------
96// ParamTrack
97// ---------------------------------------------------------------------------
98
99/// Animation track for one named parameter.
100pub struct ParamTrack {
101    /// Name of the parameter this track controls.
102    pub param_name: String,
103    /// Keyframes sorted by ascending time.
104    pub keyframes: Vec<Keyframe>,
105    /// Loop behaviour.
106    pub loop_mode: LoopMode,
107    /// Value returned before the first keyframe (when `loop_mode == Clamp`).
108    pub pre_infinity: f32,
109    /// Value returned after the last keyframe (when `loop_mode == Clamp`).
110    pub post_infinity: f32,
111}
112
113impl ParamTrack {
114    /// Create a new empty track.
115    pub fn new(param_name: &str) -> Self {
116        Self {
117            param_name: param_name.to_owned(),
118            keyframes: Vec::new(),
119            loop_mode: LoopMode::Clamp,
120            pre_infinity: 0.0,
121            post_infinity: 0.0,
122        }
123    }
124
125    /// Append a keyframe (will be sorted when `sort_keyframes` is called, or
126    /// evaluate will sort implicitly on the first call).
127    pub fn add_keyframe(&mut self, kf: Keyframe) {
128        self.keyframes.push(kf);
129        // Keep sorted so evaluate is always correct.
130        self.sort_keyframes();
131    }
132
133    /// Sort keyframes by ascending time.
134    pub fn sort_keyframes(&mut self) {
135        self.keyframes.sort_by(|a, b| {
136            a.time
137                .partial_cmp(&b.time)
138                .unwrap_or(std::cmp::Ordering::Equal)
139        });
140    }
141
142    /// Time of the last keyframe (or `0.0` if no keyframes).
143    pub fn duration(&self) -> f32 {
144        self.keyframes.last().map(|k| k.time).unwrap_or(0.0)
145    }
146
147    /// Number of keyframes.
148    pub fn frame_count(&self) -> usize {
149        self.keyframes.len()
150    }
151
152    /// Apply `loop_mode` to a raw time value and return the normalised local
153    /// time in [first_kf.time, last_kf.time].
154    fn apply_loop(&self, t: f32) -> Option<f32> {
155        if self.keyframes.is_empty() {
156            return None;
157        }
158        let first = self.keyframes.first().map_or(0.0, |k| k.time);
159        let last = self.keyframes.last().map_or(0.0, |k| k.time);
160        let span = last - first;
161
162        if span <= 0.0 {
163            return Some(first);
164        }
165
166        let local = match self.loop_mode {
167            LoopMode::Clamp => t.clamp(first, last),
168            LoopMode::Loop => {
169                let offset = t - first;
170                let wrapped = offset.rem_euclid(span);
171                first + wrapped
172            }
173            LoopMode::PingPong => {
174                let offset = t - first;
175                let cycle = span * 2.0;
176                let wrapped = offset.rem_euclid(cycle);
177                let ping = if wrapped <= span {
178                    wrapped
179                } else {
180                    cycle - wrapped
181                };
182                first + ping
183            }
184        };
185        Some(local)
186    }
187
188    /// Evaluate the track at time `t`.
189    pub fn evaluate(&self, t: f32) -> f32 {
190        if self.keyframes.is_empty() {
191            return 0.0;
192        }
193        if self.keyframes.len() == 1 {
194            return self.keyframes[0].value;
195        }
196
197        let first = &self.keyframes[0];
198        let last = &self.keyframes[self.keyframes.len() - 1];
199
200        // Handle out-of-range for Clamp mode
201        if self.loop_mode == LoopMode::Clamp {
202            if t < first.time {
203                return self.pre_infinity;
204            }
205            if t > last.time {
206                return self.post_infinity;
207            }
208        }
209
210        let local_t = match self.apply_loop(t) {
211            Some(v) => v,
212            None => return 0.0,
213        };
214
215        // Binary-search for the bracket [idx_a, idx_b].
216        let idx_b = self.keyframes.partition_point(|k| k.time <= local_t);
217
218        // Clamp to valid range
219        if idx_b == 0 {
220            return self.keyframes[0].value;
221        }
222        if idx_b >= self.keyframes.len() {
223            return self.keyframes[self.keyframes.len() - 1].value;
224        }
225
226        let idx_a = idx_b - 1;
227        let ka = &self.keyframes[idx_a];
228        let kb = &self.keyframes[idx_b];
229
230        let span = kb.time - ka.time;
231        let frac = if span > 0.0 {
232            (local_t - ka.time) / span
233        } else {
234            0.0
235        };
236
237        interpolate(ka.value, kb.value, frac, &ka.interp, ka.tan_out, kb.tan_in)
238    }
239
240    /// Sample `sample_count` evenly-spaced values across `[0, duration]`.
241    /// Returns `(time, value)` pairs.
242    pub fn bake(&self, sample_count: usize) -> Vec<(f32, f32)> {
243        if sample_count == 0 || self.keyframes.is_empty() {
244            return Vec::new();
245        }
246        let end = self.duration();
247        (0..sample_count)
248            .map(|i| {
249                let t = if sample_count == 1 {
250                    0.0
251                } else {
252                    end * (i as f32 / (sample_count - 1) as f32)
253                };
254                (t, self.evaluate(t))
255            })
256            .collect()
257    }
258}
259
260// ---------------------------------------------------------------------------
261// ParamClip
262// ---------------------------------------------------------------------------
263
264/// A full animation clip: multiple tracks for multiple parameters.
265pub struct ParamClip {
266    /// Human-readable clip name.
267    pub name: String,
268    /// All tracks in this clip.
269    pub tracks: Vec<ParamTrack>,
270    /// Intended frames per second.
271    pub fps: f32,
272}
273
274impl ParamClip {
275    /// Create a new empty clip with default 30 fps.
276    pub fn new(name: &str) -> Self {
277        Self {
278            name: name.to_owned(),
279            tracks: Vec::new(),
280            fps: 30.0,
281        }
282    }
283
284    /// Add a track to the clip.
285    pub fn add_track(&mut self, track: ParamTrack) {
286        self.tracks.push(track);
287    }
288
289    /// Find the first track whose `param_name` matches `param`.
290    pub fn find_track(&self, param: &str) -> Option<&ParamTrack> {
291        self.tracks.iter().find(|t| t.param_name == param)
292    }
293
294    /// Number of tracks.
295    pub fn track_count(&self) -> usize {
296        self.tracks.len()
297    }
298
299    /// Maximum duration across all tracks.
300    pub fn duration(&self) -> f32 {
301        self.tracks
302            .iter()
303            .map(|t| t.duration())
304            .fold(0.0_f32, f32::max)
305    }
306
307    /// Evaluate all tracks at time `t`, returning a `HashMap` of param_name → value.
308    pub fn evaluate_all(&self, t: f32) -> HashMap<String, f32> {
309        self.tracks
310            .iter()
311            .map(|tr| (tr.param_name.clone(), tr.evaluate(t)))
312            .collect()
313    }
314
315    /// Bake all tracks to a list of frames at the given `fps`.
316    /// Each frame is a `HashMap<param_name, value>`.
317    pub fn bake_all(&self, fps: f32) -> Vec<HashMap<String, f32>> {
318        let dur = self.duration();
319        if dur <= 0.0 || fps <= 0.0 {
320            return Vec::new();
321        }
322        let dt = 1.0 / fps;
323        let frame_count = (dur * fps).ceil() as usize + 1;
324        (0..frame_count)
325            .map(|i| {
326                let t = (i as f32 * dt).min(dur);
327                self.evaluate_all(t)
328            })
329            .collect()
330    }
331
332    /// Scale all keyframe times by `factor`.
333    pub fn scale_time(&mut self, factor: f32) {
334        for track in &mut self.tracks {
335            for kf in &mut track.keyframes {
336                kf.time *= factor;
337            }
338        }
339    }
340
341    /// Shift all keyframe times by `offset`.
342    pub fn shift_time(&mut self, offset: f32) {
343        for track in &mut self.tracks {
344            for kf in &mut track.keyframes {
345                kf.time += offset;
346            }
347        }
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Interpolation functions
353// ---------------------------------------------------------------------------
354
355/// Smoothstep: 3t^2 - 2t^3.
356#[inline]
357pub fn smoothstep_interp(t: f32) -> f32 {
358    let t = t.clamp(0.0, 1.0);
359    t * t * (3.0 - 2.0 * t)
360}
361
362/// Cubic Hermite spline: interpolates from `p0` to `p1` with tangents `m0` and `m1`.
363pub fn cubic_hermite(p0: f32, p1: f32, m0: f32, m1: f32, t: f32) -> f32 {
364    let t2 = t * t;
365    let t3 = t2 * t;
366    (2.0 * t3 - 3.0 * t2 + 1.0) * p0
367        + (t3 - 2.0 * t2 + t) * m0
368        + (-2.0 * t3 + 3.0 * t2) * p1
369        + (t3 - t2) * m1
370}
371
372/// Interpolate between `a` and `b` at parameter `t` (in `[0,1]`) using the
373/// given `InterpMode`. `tan_out` is the outgoing tangent from `a`, `tan_in`
374/// is the incoming tangent into `b` (used for `Bezier`).
375#[allow(clippy::too_many_arguments)]
376pub fn interpolate(a: f32, b: f32, t: f32, mode: &InterpMode, tan_out: f32, tan_in: f32) -> f32 {
377    let t = t.clamp(0.0, 1.0);
378    match mode {
379        InterpMode::Step => a,
380        InterpMode::Linear => a + (b - a) * t,
381        InterpMode::Smooth => {
382            let s = smoothstep_interp(t);
383            a + (b - a) * s
384        }
385        InterpMode::Bezier => cubic_hermite(a, b, tan_out, tan_in, t),
386        InterpMode::Sine => {
387            // Ease in/out using cosine
388            let s = 0.5 - 0.5 * (std::f32::consts::PI * t).cos();
389            a + (b - a) * s
390        }
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Factory functions
396// ---------------------------------------------------------------------------
397
398/// Create a breathing animation clip.
399///
400/// Produces a looping clip with:
401/// - `chest_expand`: chest expansion driven by a sine-like curve.
402/// - `belly_push`: slight belly movement.
403/// - `weight_shift`: subtle lateral weight shift.
404pub fn breathing_clip(breath_rate_hz: f32) -> ParamClip {
405    let mut clip = ParamClip::new("breathing");
406    let period = if breath_rate_hz > 0.0 {
407        1.0 / breath_rate_hz
408    } else {
409        4.0
410    };
411    let half = period * 0.5;
412
413    // --- chest_expand ---
414    let mut chest = ParamTrack::new("chest_expand");
415    chest.loop_mode = LoopMode::Loop;
416    chest.add_keyframe(Keyframe::smooth(0.0, 0.0));
417    chest.add_keyframe(Keyframe::smooth(half, 1.0));
418    chest.add_keyframe(Keyframe::smooth(period, 0.0));
419    clip.add_track(chest);
420
421    // --- belly_push ---
422    let mut belly = ParamTrack::new("belly_push");
423    belly.loop_mode = LoopMode::Loop;
424    belly.add_keyframe(Keyframe::smooth(0.0, 0.0));
425    belly.add_keyframe(Keyframe::smooth(half * 0.4, 0.6));
426    belly.add_keyframe(Keyframe::smooth(half, 0.8));
427    belly.add_keyframe(Keyframe::smooth(period, 0.0));
428    clip.add_track(belly);
429
430    // --- weight_shift (subtle lateral) ---
431    let mut shift = ParamTrack::new("weight_shift");
432    shift.loop_mode = LoopMode::Loop;
433    shift.add_keyframe(Keyframe {
434        time: 0.0,
435        value: 0.5,
436        interp: InterpMode::Sine,
437        tan_in: 0.0,
438        tan_out: 0.0,
439    });
440    shift.add_keyframe(Keyframe {
441        time: half,
442        value: 0.55,
443        interp: InterpMode::Sine,
444        tan_in: 0.0,
445        tan_out: 0.0,
446    });
447    shift.add_keyframe(Keyframe {
448        time: period,
449        value: 0.5,
450        interp: InterpMode::Sine,
451        tan_in: 0.0,
452        tan_out: 0.0,
453    });
454    clip.add_track(shift);
455
456    clip
457}
458
459/// Create a morph blend animation that fades from expression `from` to
460/// expression `to` over `duration` seconds.
461pub fn blend_clip(from: &str, to: &str, duration: f32) -> ParamClip {
462    let name = format!("blend_{}_to_{}", from, to);
463    let mut clip = ParamClip::new(&name);
464
465    // Track for the "from" expression weight: 1.0 → 0.0
466    let mut from_track = ParamTrack::new(from);
467    from_track.add_keyframe(Keyframe::smooth(0.0, 1.0));
468    from_track.add_keyframe(Keyframe::smooth(duration, 0.0));
469    clip.add_track(from_track);
470
471    // Track for the "to" expression weight: 0.0 → 1.0
472    let mut to_track = ParamTrack::new(to);
473    to_track.add_keyframe(Keyframe::smooth(0.0, 0.0));
474    to_track.add_keyframe(Keyframe::smooth(duration, 1.0));
475    clip.add_track(to_track);
476
477    clip
478}
479
480// ---------------------------------------------------------------------------
481// Tests
482// ---------------------------------------------------------------------------
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::fs;
488
489    // ---- smoothstep_interp ----
490
491    #[test]
492    fn smoothstep_boundaries() {
493        assert!((smoothstep_interp(0.0) - 0.0).abs() < 1e-6);
494        assert!((smoothstep_interp(1.0) - 1.0).abs() < 1e-6);
495    }
496
497    #[test]
498    fn smoothstep_midpoint() {
499        // smoothstep(0.5) = 0.25 * (3 - 1) = 0.5
500        assert!((smoothstep_interp(0.5) - 0.5).abs() < 1e-6);
501    }
502
503    // ---- cubic_hermite ----
504
505    #[test]
506    fn cubic_hermite_endpoints() {
507        // At t=0, result should be p0; at t=1, result should be p1.
508        let p0 = 0.2_f32;
509        let p1 = 0.8_f32;
510        assert!((cubic_hermite(p0, p1, 0.0, 0.0, 0.0) - p0).abs() < 1e-6);
511        assert!((cubic_hermite(p0, p1, 0.0, 0.0, 1.0) - p1).abs() < 1e-6);
512    }
513
514    // ---- interpolate ----
515
516    #[test]
517    fn interpolate_step_holds_a() {
518        let v = interpolate(0.3, 0.9, 0.8, &InterpMode::Step, 0.0, 0.0);
519        assert!((v - 0.3).abs() < 1e-6);
520    }
521
522    #[test]
523    fn interpolate_linear_midpoint() {
524        let v = interpolate(0.0, 1.0, 0.5, &InterpMode::Linear, 0.0, 0.0);
525        assert!((v - 0.5).abs() < 1e-6);
526    }
527
528    #[test]
529    fn interpolate_smooth_midpoint() {
530        // smoothstep(0.5) = 0.5 → lerp(0,1,0.5) = 0.5
531        let v = interpolate(0.0, 1.0, 0.5, &InterpMode::Smooth, 0.0, 0.0);
532        assert!((v - 0.5).abs() < 1e-6);
533    }
534
535    #[test]
536    fn interpolate_sine_boundaries() {
537        let a = interpolate(0.0, 1.0, 0.0, &InterpMode::Sine, 0.0, 0.0);
538        let b = interpolate(0.0, 1.0, 1.0, &InterpMode::Sine, 0.0, 0.0);
539        assert!(a.abs() < 1e-6);
540        assert!((b - 1.0).abs() < 1e-6);
541    }
542
543    // ---- Keyframe constructors ----
544
545    #[test]
546    fn keyframe_new_is_linear() {
547        let kf = Keyframe::new(1.0, 0.5);
548        assert_eq!(kf.interp, InterpMode::Linear);
549        assert!((kf.time - 1.0).abs() < 1e-6);
550        assert!((kf.value - 0.5).abs() < 1e-6);
551    }
552
553    #[test]
554    fn keyframe_step_constructor() {
555        let kf = Keyframe::step(2.0, 0.7);
556        assert_eq!(kf.interp, InterpMode::Step);
557    }
558
559    #[test]
560    fn keyframe_smooth_constructor() {
561        let kf = Keyframe::smooth(3.0, 0.3);
562        assert_eq!(kf.interp, InterpMode::Smooth);
563    }
564
565    // ---- ParamTrack ----
566
567    #[test]
568    fn param_track_evaluate_linear() {
569        let mut track = ParamTrack::new("test");
570        track.add_keyframe(Keyframe::new(0.0, 0.0));
571        track.add_keyframe(Keyframe::new(1.0, 1.0));
572        let v = track.evaluate(0.5);
573        assert!((v - 0.5).abs() < 1e-5);
574    }
575
576    #[test]
577    fn param_track_evaluate_step() {
578        let mut track = ParamTrack::new("step_track");
579        track.add_keyframe(Keyframe::step(0.0, 0.0));
580        track.add_keyframe(Keyframe::step(1.0, 1.0));
581        // Before second keyframe: holds 0.0
582        let v = track.evaluate(0.5);
583        assert!((v - 0.0).abs() < 1e-5);
584    }
585
586    #[test]
587    fn param_track_clamp_pre_post() {
588        let mut track = ParamTrack::new("clamp");
589        track.loop_mode = LoopMode::Clamp;
590        track.pre_infinity = 0.1;
591        track.post_infinity = 0.9;
592        track.add_keyframe(Keyframe::new(1.0, 0.2));
593        track.add_keyframe(Keyframe::new(2.0, 0.8));
594        // Before first keyframe → pre_infinity
595        let pre = track.evaluate(0.0);
596        assert!((pre - 0.1).abs() < 1e-5, "pre={pre}");
597        // After last keyframe → post_infinity
598        let post = track.evaluate(5.0);
599        assert!((post - 0.9).abs() < 1e-5, "post={post}");
600    }
601
602    #[test]
603    fn param_track_loop_mode() {
604        let mut track = ParamTrack::new("loop_track");
605        track.loop_mode = LoopMode::Loop;
606        track.add_keyframe(Keyframe::new(0.0, 0.0));
607        track.add_keyframe(Keyframe::new(1.0, 1.0));
608        // At t=1.5 (0.5 into second cycle) should equal t=0.5
609        let v_original = track.evaluate(0.5);
610        let v_looped = track.evaluate(1.5);
611        assert!((v_original - v_looped).abs() < 1e-4);
612    }
613
614    #[test]
615    fn param_track_pingpong_mode() {
616        let mut track = ParamTrack::new("pp");
617        track.loop_mode = LoopMode::PingPong;
618        track.add_keyframe(Keyframe::new(0.0, 0.0));
619        track.add_keyframe(Keyframe::new(1.0, 1.0));
620        // At t=1.5 in pingpong should equal t=0.5 going backward
621        let v_fwd = track.evaluate(0.5); // 0.5
622        let v_rev = track.evaluate(1.5); // should also be ~0.5
623        assert!((v_fwd - v_rev).abs() < 1e-4);
624    }
625
626    #[test]
627    fn param_track_duration_and_frame_count() {
628        let mut track = ParamTrack::new("dur");
629        track.add_keyframe(Keyframe::new(0.0, 0.0));
630        track.add_keyframe(Keyframe::new(3.0, 1.0));
631        assert!((track.duration() - 3.0).abs() < 1e-5);
632        assert_eq!(track.frame_count(), 2);
633    }
634
635    #[test]
636    fn param_track_bake_count() {
637        let mut track = ParamTrack::new("bake");
638        track.add_keyframe(Keyframe::new(0.0, 0.0));
639        track.add_keyframe(Keyframe::new(1.0, 1.0));
640        let samples = track.bake(11);
641        assert_eq!(samples.len(), 11);
642        // First sample at t=0, value=0.0
643        assert!((samples[0].1 - 0.0).abs() < 1e-5);
644        // Last sample at t=1, value=1.0
645        assert!((samples[10].1 - 1.0).abs() < 1e-5);
646    }
647
648    // ---- ParamClip ----
649
650    #[test]
651    fn param_clip_find_track() {
652        let mut clip = ParamClip::new("test_clip");
653        let mut track = ParamTrack::new("jaw_open");
654        track.add_keyframe(Keyframe::new(0.0, 0.0));
655        clip.add_track(track);
656        assert!(clip.find_track("jaw_open").is_some());
657        assert!(clip.find_track("missing").is_none());
658    }
659
660    #[test]
661    fn param_clip_duration_max() {
662        let mut clip = ParamClip::new("multi");
663        let mut t1 = ParamTrack::new("a");
664        t1.add_keyframe(Keyframe::new(0.0, 0.0));
665        t1.add_keyframe(Keyframe::new(2.0, 1.0));
666        let mut t2 = ParamTrack::new("b");
667        t2.add_keyframe(Keyframe::new(0.0, 0.0));
668        t2.add_keyframe(Keyframe::new(5.0, 1.0));
669        clip.add_track(t1);
670        clip.add_track(t2);
671        assert!((clip.duration() - 5.0).abs() < 1e-5);
672    }
673
674    #[test]
675    fn param_clip_evaluate_all() {
676        let mut clip = ParamClip::new("eval");
677        let mut tr = ParamTrack::new("smile");
678        tr.add_keyframe(Keyframe::new(0.0, 0.0));
679        tr.add_keyframe(Keyframe::new(1.0, 1.0));
680        clip.add_track(tr);
681        let map = clip.evaluate_all(0.5);
682        let v = map["smile"];
683        assert!((v - 0.5).abs() < 1e-5);
684    }
685
686    #[test]
687    fn param_clip_scale_shift_time() {
688        let mut clip = ParamClip::new("scale_shift");
689        let mut tr = ParamTrack::new("p");
690        tr.add_keyframe(Keyframe::new(0.0, 0.0));
691        tr.add_keyframe(Keyframe::new(2.0, 1.0));
692        clip.add_track(tr);
693        clip.scale_time(2.0);
694        assert!((clip.duration() - 4.0).abs() < 1e-5);
695        clip.shift_time(1.0);
696        assert!((clip.duration() - 5.0).abs() < 1e-5);
697    }
698
699    #[test]
700    fn param_clip_bake_all_frames() {
701        let mut clip = ParamClip::new("bake_all");
702        let mut tr = ParamTrack::new("eye");
703        tr.add_keyframe(Keyframe::new(0.0, 0.0));
704        tr.add_keyframe(Keyframe::new(1.0, 1.0));
705        clip.add_track(tr);
706        let frames = clip.bake_all(10.0);
707        // 10fps over 1s → ~11 frames
708        assert!(!frames.is_empty());
709        assert!(frames[0].contains_key("eye"));
710    }
711
712    // ---- Factory functions ----
713
714    #[test]
715    fn breathing_clip_has_three_tracks() {
716        let clip = breathing_clip(0.25); // 4-second period
717        assert_eq!(clip.track_count(), 3);
718        assert!(clip.find_track("chest_expand").is_some());
719        assert!(clip.find_track("belly_push").is_some());
720        assert!(clip.find_track("weight_shift").is_some());
721    }
722
723    #[test]
724    fn breathing_clip_loops() {
725        let clip = breathing_clip(1.0); // 1Hz = 1s period
726        let track = clip.find_track("chest_expand").expect("should succeed");
727        assert_eq!(track.loop_mode, LoopMode::Loop);
728        // At t=0 and t=1 (one full loop), values should be equal
729        let v0 = track.evaluate(0.0);
730        let v1 = track.evaluate(1.0);
731        assert!((v0 - v1).abs() < 1e-4, "v0={v0}, v1={v1}");
732    }
733
734    #[test]
735    fn blend_clip_two_tracks() {
736        let clip = blend_clip("neutral", "happy", 2.0);
737        assert_eq!(clip.track_count(), 2);
738        assert!(clip.find_track("neutral").is_some());
739        assert!(clip.find_track("happy").is_some());
740        // At t=0: neutral=1.0, happy=0.0
741        let map0 = clip.evaluate_all(0.0);
742        assert!((map0["neutral"] - 1.0).abs() < 1e-5);
743        assert!((map0["happy"] - 0.0).abs() < 1e-5);
744        // At t=2: neutral=0.0, happy=1.0
745        let map2 = clip.evaluate_all(2.0);
746        assert!((map2["neutral"] - 0.0).abs() < 1e-5);
747        assert!((map2["happy"] - 1.0).abs() < 1e-5);
748    }
749
750    #[test]
751    fn blend_clip_name_contains_expressions() {
752        let clip = blend_clip("sad", "surprised", 1.0);
753        assert!(clip.name.contains("sad"));
754        assert!(clip.name.contains("surprised"));
755    }
756
757    // ---- File output test ----
758
759    #[test]
760    fn bake_writes_to_tmp() {
761        let mut clip = ParamClip::new("write_test");
762        let mut tr = ParamTrack::new("brow");
763        tr.add_keyframe(Keyframe::new(0.0, 0.0));
764        tr.add_keyframe(Keyframe::new(1.0, 1.0));
765        clip.add_track(tr);
766        let frames = clip.bake_all(5.0);
767        let mut lines = Vec::new();
768        for (i, frame) in frames.iter().enumerate() {
769            let v = frame.get("brow").copied().unwrap_or(0.0);
770            lines.push(format!("frame {i}: brow={v:.4}"));
771        }
772        let output = lines.join("\n");
773        fs::write("/tmp/param_animation_bake_test.txt", &output).expect("should succeed");
774        let read_back =
775            fs::read_to_string("/tmp/param_animation_bake_test.txt").expect("should succeed");
776        assert!(read_back.contains("brow="));
777    }
778
779    #[test]
780    fn bezier_interpolate_boundaries() {
781        let a = interpolate(0.0, 1.0, 0.0, &InterpMode::Bezier, 0.0, 0.0);
782        let b = interpolate(0.0, 1.0, 1.0, &InterpMode::Bezier, 0.0, 0.0);
783        assert!(a.abs() < 1e-6);
784        assert!((b - 1.0).abs() < 1e-6);
785    }
786
787    #[test]
788    fn param_clip_track_count() {
789        let mut clip = ParamClip::new("count_test");
790        assert_eq!(clip.track_count(), 0);
791        clip.add_track(ParamTrack::new("a"));
792        clip.add_track(ParamTrack::new("b"));
793        assert_eq!(clip.track_count(), 2);
794    }
795}