Skip to main content

oxihuman_cli/commands/
anim_params.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Time-varying morph-parameter sources for the `pc2` / `mdd` / `anim-bake`
5//! animation export commands.
6//!
7//! Two JSON shapes are accepted by [`load_anim_source`]:
8//!
9//! 1. **Dense snapshot array** — one fully-resolved parameter object per
10//!    output frame, e.g. `[{"height":0.0,"weight":0.5}, {"height":0.5,...}]`.
11//!    This is the format produced by `WasmEngine::export_anim_json` (see
12//!    `oxihuman-wasm/src/engine_anim.rs`). The array length fixes the frame
13//!    count; unspecified fields fall back to [`ParamState::default`] and any
14//!    unrecognised keys are stored in [`ParamState::extra`].
15//!
16//! 2. **Keyframed animation curve** — `{"interp":"linear","keyframes":[
17//!    {"time":0.0,"params":{"height":0.0}},
18//!    {"time":1.0,"params":{"height":1.0}}]}`. Each named parameter is
19//!    interpolated independently via [`oxihuman_core::animation_curve::AnimCurve`]
20//!    and sampled at `t = start_time + frame_index / fps`, matching the
21//!    record/seek/play semantics mirrored in `oxihuman-wasm`'s `engine_anim`.
22//!    Optional per-keyframe `tan_in` / `tan_out` objects supply Hermite
23//!    tangents for `"interp":"cubic"`.
24
25use std::collections::HashMap;
26use std::path::Path;
27
28use anyhow::{bail, Context, Result};
29use serde_json::Value;
30
31use oxihuman_core::animation_curve::{AnimCurve, InterpMode, Keyframe};
32use oxihuman_morph::params::ParamState;
33
34/// A source of time-varying morph parameters for baking animated frame
35/// sequences. See the module docs for the accepted JSON shapes.
36#[derive(Debug, Clone)]
37pub enum AnimSource {
38    /// One fully-resolved [`ParamState`] per output frame (frame count is fixed).
39    Snapshots(Vec<ParamState>),
40    /// Continuous per-parameter animation curves, keyed by parameter name,
41    /// sampled at an arbitrary time in seconds.
42    Curves(HashMap<String, AnimCurve>),
43}
44
45impl AnimSource {
46    /// The frame count implied by this source, if it is fixed (dense form only).
47    pub fn fixed_frame_count(&self) -> Option<usize> {
48        match self {
49            AnimSource::Snapshots(snaps) => Some(snaps.len()),
50            AnimSource::Curves(_) => None,
51        }
52    }
53
54    /// A reasonable default frame count for curve sources: enough frames at
55    /// `fps` to cover the longest-running parameter curve, at least one frame.
56    pub fn suggested_frame_count(&self, fps: f32) -> usize {
57        match self {
58            AnimSource::Snapshots(snaps) => snaps.len().max(1),
59            AnimSource::Curves(curves) => {
60                let max_duration = curves
61                    .values()
62                    .map(|c| c.duration())
63                    .fold(0.0_f32, f32::max);
64                let safe_fps = if fps > 0.0 { fps } else { 1.0 };
65                ((max_duration * safe_fps).round() as usize + 1).max(1)
66            }
67        }
68    }
69
70    /// Resolve the [`ParamState`] to apply for output frame `i`.
71    ///
72    /// For the dense (snapshot) form, `i` indexes directly into the array
73    /// (clamped to the last entry so callers never index out of range). For
74    /// the curve form, each named parameter curve is sampled at
75    /// `t = start_time + i / fps`.
76    pub fn params_at_frame(&self, i: usize, fps: f32, start_time: f32) -> ParamState {
77        match self {
78            AnimSource::Snapshots(snaps) => {
79                let idx = i.min(snaps.len().saturating_sub(1));
80                snaps.get(idx).cloned().unwrap_or_else(ParamState::default)
81            }
82            AnimSource::Curves(curves) => {
83                let safe_fps = if fps > 0.0 { fps } else { 1.0 };
84                let t = start_time + (i as f32) / safe_fps;
85                let mut params = ParamState::default();
86                for (name, curve) in curves {
87                    apply_named_value(&mut params, name, curve.evaluate(t));
88                }
89                params
90            }
91        }
92    }
93}
94
95/// Load an [`AnimSource`] from a JSON file on disk.
96pub fn load_anim_source(path: &Path) -> Result<AnimSource> {
97    let src = std::fs::read_to_string(path)
98        .with_context(|| format!("reading anim JSON: {}", path.display()))?;
99    let value: Value = serde_json::from_str(&src)
100        .with_context(|| format!("parsing anim JSON: {}", path.display()))?;
101    parse_anim_value(&value)
102}
103
104/// Write `value` into the matching field of `params` (built-ins get their
105/// dedicated field, everything else lands in `extra`).
106fn apply_named_value(params: &mut ParamState, name: &str, value: f32) {
107    match name {
108        "height" => params.height = value,
109        "weight" => params.weight = value,
110        "muscle" => params.muscle = value,
111        "age" => params.age = value,
112        other => {
113            params.extra.insert(other.to_string(), value);
114        }
115    }
116}
117
118/// A dense-form array element is any object that is *not* a keyframe wrapper
119/// (i.e. it lacks the `time` + `params` pairing used by the curve form).
120fn looks_like_keyframe(v: &Value) -> bool {
121    matches!(v, Value::Object(map) if map.contains_key("time") && map.contains_key("params"))
122}
123
124/// Parse a lenient parameter snapshot: known fields (`height`/`weight`/
125/// `muscle`/`age`) override the default, unknown numeric fields become
126/// `extra` entries. Non-numeric values for a known/unknown key are an error.
127fn snapshot_from_map(map: &serde_json::Map<String, Value>) -> Result<ParamState> {
128    let mut params = ParamState::default();
129    for (key, value) in map {
130        let f = value
131            .as_f64()
132            .with_context(|| format!("param '{}' must be a number", key))? as f32;
133        apply_named_value(&mut params, key, f);
134    }
135    Ok(params)
136}
137
138fn parse_anim_value(value: &Value) -> Result<AnimSource> {
139    match value {
140        Value::Array(entries) => {
141            if entries.is_empty() {
142                bail!("anim JSON array must not be empty");
143            }
144            if entries.iter().all(looks_like_keyframe) {
145                Ok(AnimSource::Curves(build_curves(
146                    entries,
147                    InterpMode::Linear,
148                )?))
149            } else {
150                let snapshots = entries
151                    .iter()
152                    .map(|entry| {
153                        entry
154                            .as_object()
155                            .context("each anim JSON array entry must be an object")
156                            .and_then(snapshot_from_map)
157                    })
158                    .collect::<Result<Vec<_>>>()?;
159                Ok(AnimSource::Snapshots(snapshots))
160            }
161        }
162        Value::Object(map) => {
163            let keyframes = map
164                .get("keyframes")
165                .and_then(Value::as_array)
166                .context("anim JSON object must contain a \"keyframes\" array")?;
167            if keyframes.is_empty() {
168                bail!("\"keyframes\" array must not be empty");
169            }
170            let interp = match map.get("interp").and_then(Value::as_str) {
171                None | Some("linear") => InterpMode::Linear,
172                Some("step") => InterpMode::Step,
173                Some("cubic") => InterpMode::Cubic,
174                Some(other) => bail!("unknown interp mode '{}': use linear|step|cubic", other),
175            };
176            Ok(AnimSource::Curves(build_curves(keyframes, interp)?))
177        }
178        _ => bail!("anim JSON must be an array or an object with a \"keyframes\" array"),
179    }
180}
181
182/// Build one [`AnimCurve`] per named parameter from a list of keyframe
183/// objects `{"time":f32,"params":{name: value, ...},"tan_in":{...},"tan_out":{...}}`.
184fn build_curves(entries: &[Value], interp: InterpMode) -> Result<HashMap<String, AnimCurve>> {
185    let mut curves: HashMap<String, AnimCurve> = HashMap::new();
186    for entry in entries {
187        let obj = entry
188            .as_object()
189            .context("each keyframe must be a JSON object")?;
190        let time = obj
191            .get("time")
192            .and_then(Value::as_f64)
193            .context("keyframe missing numeric \"time\"")? as f32;
194        let params = obj
195            .get("params")
196            .and_then(Value::as_object)
197            .context("keyframe missing \"params\" object")?;
198        let tan_in = obj.get("tan_in").and_then(Value::as_object);
199        let tan_out = obj.get("tan_out").and_then(Value::as_object);
200
201        for (name, raw_value) in params {
202            let value = raw_value
203                .as_f64()
204                .with_context(|| format!("keyframe param '{}' must be a number", name))?
205                as f32;
206            let t_in = tan_in
207                .and_then(|m| m.get(name))
208                .and_then(Value::as_f64)
209                .unwrap_or(0.0) as f32;
210            let t_out = tan_out
211                .and_then(|m| m.get(name))
212                .and_then(Value::as_f64)
213                .unwrap_or(0.0) as f32;
214            curves
215                .entry(name.clone())
216                .or_insert_with(|| AnimCurve::new(interp))
217                .insert(Keyframe::with_tangents(time, value, t_in, t_out));
218        }
219    }
220    Ok(curves)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    // 1. dense array with full snapshots parses and preserves values.
228    #[test]
229    fn dense_snapshots_parse() {
230        let v: Value = serde_json::from_str(
231            r#"[{"height":0.1,"weight":0.5,"muscle":0.5,"age":0.5},
232                {"height":0.9,"weight":0.5,"muscle":0.5,"age":0.5}]"#,
233        )
234        .expect("valid json");
235        let src = parse_anim_value(&v).expect("should parse");
236        assert_eq!(src.fixed_frame_count(), Some(2));
237        let p0 = src.params_at_frame(0, 24.0, 0.0);
238        let p1 = src.params_at_frame(1, 24.0, 0.0);
239        assert!((p0.height - 0.1).abs() < 1e-6);
240        assert!((p1.height - 0.9).abs() < 1e-6);
241    }
242
243    // 2. dense array with partial fields defaults the rest and keeps extras.
244    #[test]
245    fn dense_snapshots_partial_fields_default_and_extra() {
246        let v: Value =
247            serde_json::from_str(r#"[{"height":0.2,"custom":0.75}]"#).expect("valid json");
248        let src = parse_anim_value(&v).expect("should parse");
249        let p = src.params_at_frame(0, 24.0, 0.0);
250        assert!((p.height - 0.2).abs() < 1e-6);
251        assert!((p.weight - ParamState::default().weight).abs() < 1e-6);
252        assert_eq!(p.extra.get("custom").copied(), Some(0.75));
253    }
254
255    // 3. snapshot index clamps to the last frame rather than panicking.
256    #[test]
257    fn dense_snapshots_index_clamps() {
258        let v: Value = serde_json::from_str(r#"[{"height":0.3}]"#).expect("valid json");
259        let src = parse_anim_value(&v).expect("should parse");
260        let p = src.params_at_frame(50, 24.0, 0.0);
261        assert!((p.height - 0.3).abs() < 1e-6);
262    }
263
264    // 4. keyframe object form builds a sampleable curve.
265    #[test]
266    fn keyframe_object_form_samples_linearly() {
267        let v: Value = serde_json::from_str(
268            r#"{"interp":"linear","keyframes":[
269                {"time":0.0,"params":{"height":0.0}},
270                {"time":1.0,"params":{"height":1.0}}]}"#,
271        )
272        .expect("valid json");
273        let src = parse_anim_value(&v).expect("should parse");
274        assert_eq!(src.fixed_frame_count(), None);
275        let p_mid = src.params_at_frame(12, 24.0, 0.0); // t = 0.5s
276        assert!((p_mid.height - 0.5).abs() < 1e-3);
277    }
278
279    // 5. step interpolation holds the previous keyframe's value.
280    #[test]
281    fn keyframe_step_holds_value() {
282        let v: Value = serde_json::from_str(
283            r#"{"interp":"step","keyframes":[
284                {"time":0.0,"params":{"weight":0.2}},
285                {"time":2.0,"params":{"weight":0.8}}]}"#,
286        )
287        .expect("valid json");
288        let src = parse_anim_value(&v).expect("should parse");
289        let p = src.params_at_frame(24, 24.0, 0.0); // t = 1.0s, before next keyframe
290        assert!((p.weight - 0.2).abs() < 1e-6);
291    }
292
293    // 6. bare (unwrapped) keyframe array is also accepted.
294    #[test]
295    fn bare_keyframe_array_accepted() {
296        let v: Value = serde_json::from_str(
297            r#"[{"time":0.0,"params":{"age":0.1}},{"time":2.0,"params":{"age":0.9}}]"#,
298        )
299        .expect("valid json");
300        let src = parse_anim_value(&v).expect("should parse");
301        assert_eq!(src.fixed_frame_count(), None);
302    }
303
304    // 7. suggested_frame_count derives from curve duration and fps.
305    #[test]
306    fn suggested_frame_count_matches_duration() {
307        let v: Value = serde_json::from_str(
308            r#"{"keyframes":[{"time":0.0,"params":{"height":0.0}},
309                              {"time":2.0,"params":{"height":1.0}}]}"#,
310        )
311        .expect("valid json");
312        let src = parse_anim_value(&v).expect("should parse");
313        // 2 seconds at 10 fps -> 20 steps -> 21 frames (inclusive of both ends)
314        assert_eq!(src.suggested_frame_count(10.0), 21);
315    }
316
317    // 8. empty array is rejected.
318    #[test]
319    fn empty_array_rejected() {
320        let v: Value = serde_json::from_str("[]").expect("valid json");
321        assert!(parse_anim_value(&v).is_err());
322    }
323
324    // 9. object without "keyframes" is rejected.
325    #[test]
326    fn object_without_keyframes_rejected() {
327        let v: Value = serde_json::from_str(r#"{"foo":1}"#).expect("valid json");
328        assert!(parse_anim_value(&v).is_err());
329    }
330
331    // 10. unknown interp mode is rejected.
332    #[test]
333    fn unknown_interp_rejected() {
334        let v: Value = serde_json::from_str(
335            r#"{"interp":"bogus","keyframes":[{"time":0.0,"params":{"height":0.0}}]}"#,
336        )
337        .expect("valid json");
338        assert!(parse_anim_value(&v).is_err());
339    }
340
341    // 11. non-numeric snapshot value is rejected.
342    #[test]
343    fn non_numeric_snapshot_value_rejected() {
344        let v: Value = serde_json::from_str(r#"[{"height":"tall"}]"#).expect("valid json");
345        assert!(parse_anim_value(&v).is_err());
346    }
347
348    // 12. top-level scalar is rejected.
349    #[test]
350    fn top_level_scalar_rejected() {
351        let v: Value = serde_json::from_str("42").expect("valid json");
352        assert!(parse_anim_value(&v).is_err());
353    }
354
355    // 13. cubic interpolation with tangents evaluates at endpoints.
356    #[test]
357    fn cubic_tangents_endpoints() {
358        let v: Value = serde_json::from_str(
359            r#"{"interp":"cubic","keyframes":[
360                {"time":0.0,"params":{"muscle":0.0},"tan_out":{"muscle":0.0}},
361                {"time":1.0,"params":{"muscle":1.0},"tan_in":{"muscle":0.0}}]}"#,
362        )
363        .expect("valid json");
364        let src = parse_anim_value(&v).expect("should parse");
365        let p0 = src.params_at_frame(0, 1.0, 0.0);
366        let p1 = src.params_at_frame(1, 1.0, 0.0);
367        assert!((p0.muscle - 0.0).abs() < 1e-3);
368        assert!((p1.muscle - 1.0).abs() < 1e-3);
369    }
370}