use std::collections::HashMap;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde_json::Value;
use oxihuman_core::animation_curve::{AnimCurve, InterpMode, Keyframe};
use oxihuman_morph::params::ParamState;
#[derive(Debug, Clone)]
pub enum AnimSource {
Snapshots(Vec<ParamState>),
Curves(HashMap<String, AnimCurve>),
}
impl AnimSource {
pub fn fixed_frame_count(&self) -> Option<usize> {
match self {
AnimSource::Snapshots(snaps) => Some(snaps.len()),
AnimSource::Curves(_) => None,
}
}
pub fn suggested_frame_count(&self, fps: f32) -> usize {
match self {
AnimSource::Snapshots(snaps) => snaps.len().max(1),
AnimSource::Curves(curves) => {
let max_duration = curves
.values()
.map(|c| c.duration())
.fold(0.0_f32, f32::max);
let safe_fps = if fps > 0.0 { fps } else { 1.0 };
((max_duration * safe_fps).round() as usize + 1).max(1)
}
}
}
pub fn params_at_frame(&self, i: usize, fps: f32, start_time: f32) -> ParamState {
match self {
AnimSource::Snapshots(snaps) => {
let idx = i.min(snaps.len().saturating_sub(1));
snaps.get(idx).cloned().unwrap_or_else(ParamState::default)
}
AnimSource::Curves(curves) => {
let safe_fps = if fps > 0.0 { fps } else { 1.0 };
let t = start_time + (i as f32) / safe_fps;
let mut params = ParamState::default();
for (name, curve) in curves {
apply_named_value(&mut params, name, curve.evaluate(t));
}
params
}
}
}
}
pub fn load_anim_source(path: &Path) -> Result<AnimSource> {
let src = std::fs::read_to_string(path)
.with_context(|| format!("reading anim JSON: {}", path.display()))?;
let value: Value = serde_json::from_str(&src)
.with_context(|| format!("parsing anim JSON: {}", path.display()))?;
parse_anim_value(&value)
}
fn apply_named_value(params: &mut ParamState, name: &str, value: f32) {
match name {
"height" => params.height = value,
"weight" => params.weight = value,
"muscle" => params.muscle = value,
"age" => params.age = value,
other => {
params.extra.insert(other.to_string(), value);
}
}
}
fn looks_like_keyframe(v: &Value) -> bool {
matches!(v, Value::Object(map) if map.contains_key("time") && map.contains_key("params"))
}
fn snapshot_from_map(map: &serde_json::Map<String, Value>) -> Result<ParamState> {
let mut params = ParamState::default();
for (key, value) in map {
let f = value
.as_f64()
.with_context(|| format!("param '{}' must be a number", key))? as f32;
apply_named_value(&mut params, key, f);
}
Ok(params)
}
fn parse_anim_value(value: &Value) -> Result<AnimSource> {
match value {
Value::Array(entries) => {
if entries.is_empty() {
bail!("anim JSON array must not be empty");
}
if entries.iter().all(looks_like_keyframe) {
Ok(AnimSource::Curves(build_curves(
entries,
InterpMode::Linear,
)?))
} else {
let snapshots = entries
.iter()
.map(|entry| {
entry
.as_object()
.context("each anim JSON array entry must be an object")
.and_then(snapshot_from_map)
})
.collect::<Result<Vec<_>>>()?;
Ok(AnimSource::Snapshots(snapshots))
}
}
Value::Object(map) => {
let keyframes = map
.get("keyframes")
.and_then(Value::as_array)
.context("anim JSON object must contain a \"keyframes\" array")?;
if keyframes.is_empty() {
bail!("\"keyframes\" array must not be empty");
}
let interp = match map.get("interp").and_then(Value::as_str) {
None | Some("linear") => InterpMode::Linear,
Some("step") => InterpMode::Step,
Some("cubic") => InterpMode::Cubic,
Some(other) => bail!("unknown interp mode '{}': use linear|step|cubic", other),
};
Ok(AnimSource::Curves(build_curves(keyframes, interp)?))
}
_ => bail!("anim JSON must be an array or an object with a \"keyframes\" array"),
}
}
fn build_curves(entries: &[Value], interp: InterpMode) -> Result<HashMap<String, AnimCurve>> {
let mut curves: HashMap<String, AnimCurve> = HashMap::new();
for entry in entries {
let obj = entry
.as_object()
.context("each keyframe must be a JSON object")?;
let time = obj
.get("time")
.and_then(Value::as_f64)
.context("keyframe missing numeric \"time\"")? as f32;
let params = obj
.get("params")
.and_then(Value::as_object)
.context("keyframe missing \"params\" object")?;
let tan_in = obj.get("tan_in").and_then(Value::as_object);
let tan_out = obj.get("tan_out").and_then(Value::as_object);
for (name, raw_value) in params {
let value = raw_value
.as_f64()
.with_context(|| format!("keyframe param '{}' must be a number", name))?
as f32;
let t_in = tan_in
.and_then(|m| m.get(name))
.and_then(Value::as_f64)
.unwrap_or(0.0) as f32;
let t_out = tan_out
.and_then(|m| m.get(name))
.and_then(Value::as_f64)
.unwrap_or(0.0) as f32;
curves
.entry(name.clone())
.or_insert_with(|| AnimCurve::new(interp))
.insert(Keyframe::with_tangents(time, value, t_in, t_out));
}
}
Ok(curves)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dense_snapshots_parse() {
let v: Value = serde_json::from_str(
r#"[{"height":0.1,"weight":0.5,"muscle":0.5,"age":0.5},
{"height":0.9,"weight":0.5,"muscle":0.5,"age":0.5}]"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
assert_eq!(src.fixed_frame_count(), Some(2));
let p0 = src.params_at_frame(0, 24.0, 0.0);
let p1 = src.params_at_frame(1, 24.0, 0.0);
assert!((p0.height - 0.1).abs() < 1e-6);
assert!((p1.height - 0.9).abs() < 1e-6);
}
#[test]
fn dense_snapshots_partial_fields_default_and_extra() {
let v: Value =
serde_json::from_str(r#"[{"height":0.2,"custom":0.75}]"#).expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
let p = src.params_at_frame(0, 24.0, 0.0);
assert!((p.height - 0.2).abs() < 1e-6);
assert!((p.weight - ParamState::default().weight).abs() < 1e-6);
assert_eq!(p.extra.get("custom").copied(), Some(0.75));
}
#[test]
fn dense_snapshots_index_clamps() {
let v: Value = serde_json::from_str(r#"[{"height":0.3}]"#).expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
let p = src.params_at_frame(50, 24.0, 0.0);
assert!((p.height - 0.3).abs() < 1e-6);
}
#[test]
fn keyframe_object_form_samples_linearly() {
let v: Value = serde_json::from_str(
r#"{"interp":"linear","keyframes":[
{"time":0.0,"params":{"height":0.0}},
{"time":1.0,"params":{"height":1.0}}]}"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
assert_eq!(src.fixed_frame_count(), None);
let p_mid = src.params_at_frame(12, 24.0, 0.0); assert!((p_mid.height - 0.5).abs() < 1e-3);
}
#[test]
fn keyframe_step_holds_value() {
let v: Value = serde_json::from_str(
r#"{"interp":"step","keyframes":[
{"time":0.0,"params":{"weight":0.2}},
{"time":2.0,"params":{"weight":0.8}}]}"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
let p = src.params_at_frame(24, 24.0, 0.0); assert!((p.weight - 0.2).abs() < 1e-6);
}
#[test]
fn bare_keyframe_array_accepted() {
let v: Value = serde_json::from_str(
r#"[{"time":0.0,"params":{"age":0.1}},{"time":2.0,"params":{"age":0.9}}]"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
assert_eq!(src.fixed_frame_count(), None);
}
#[test]
fn suggested_frame_count_matches_duration() {
let v: Value = serde_json::from_str(
r#"{"keyframes":[{"time":0.0,"params":{"height":0.0}},
{"time":2.0,"params":{"height":1.0}}]}"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
assert_eq!(src.suggested_frame_count(10.0), 21);
}
#[test]
fn empty_array_rejected() {
let v: Value = serde_json::from_str("[]").expect("valid json");
assert!(parse_anim_value(&v).is_err());
}
#[test]
fn object_without_keyframes_rejected() {
let v: Value = serde_json::from_str(r#"{"foo":1}"#).expect("valid json");
assert!(parse_anim_value(&v).is_err());
}
#[test]
fn unknown_interp_rejected() {
let v: Value = serde_json::from_str(
r#"{"interp":"bogus","keyframes":[{"time":0.0,"params":{"height":0.0}}]}"#,
)
.expect("valid json");
assert!(parse_anim_value(&v).is_err());
}
#[test]
fn non_numeric_snapshot_value_rejected() {
let v: Value = serde_json::from_str(r#"[{"height":"tall"}]"#).expect("valid json");
assert!(parse_anim_value(&v).is_err());
}
#[test]
fn top_level_scalar_rejected() {
let v: Value = serde_json::from_str("42").expect("valid json");
assert!(parse_anim_value(&v).is_err());
}
#[test]
fn cubic_tangents_endpoints() {
let v: Value = serde_json::from_str(
r#"{"interp":"cubic","keyframes":[
{"time":0.0,"params":{"muscle":0.0},"tan_out":{"muscle":0.0}},
{"time":1.0,"params":{"muscle":1.0},"tan_in":{"muscle":0.0}}]}"#,
)
.expect("valid json");
let src = parse_anim_value(&v).expect("should parse");
let p0 = src.params_at_frame(0, 1.0, 0.0);
let p1 = src.params_at_frame(1, 1.0, 0.0);
assert!((p0.muscle - 0.0).abs() < 1e-3);
assert!((p1.muscle - 1.0).abs() < 1e-3);
}
}