Skip to main content

oxihuman_export/
animation.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GLTF 2.0 morph animation keyframe export.
5
6use anyhow::{bail, Result};
7use serde_json::{json, Value};
8
9/// One keyframe: a time stamp + a set of morph target weights.
10pub struct AnimKeyframe {
11    /// Time in seconds.
12    pub time_s: f32,
13    /// One weight per morph target (0..1).
14    pub weights: Vec<f32>,
15}
16
17/// A named animation clip composed of keyframes.
18pub struct AnimClip {
19    pub name: String,
20    pub keyframes: Vec<AnimKeyframe>,
21}
22
23// ── base64 helper (std only, no external crate) ──────────────────────────────
24
25fn to_base64(data: &[u8]) -> String {
26    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
27    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
28    for chunk in data.chunks(3) {
29        let b0 = chunk[0] as u32;
30        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
31        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
32        let combined = (b0 << 16) | (b1 << 8) | b2;
33        out.push(CHARS[((combined >> 18) & 0x3F) as usize] as char);
34        out.push(CHARS[((combined >> 12) & 0x3F) as usize] as char);
35        out.push(if chunk.len() > 1 {
36            CHARS[((combined >> 6) & 0x3F) as usize] as char
37        } else {
38            '='
39        });
40        out.push(if chunk.len() > 2 {
41            CHARS[(combined & 0x3F) as usize] as char
42        } else {
43            '='
44        });
45    }
46    out
47}
48
49// ── byte helpers ─────────────────────────────────────────────────────────────
50
51/// Append a slice of f32 values as little-endian bytes.
52fn push_f32_slice(buf: &mut Vec<u8>, values: &[f32]) {
53    for &v in values {
54        buf.extend_from_slice(&v.to_le_bytes());
55    }
56}
57
58/// Flatten `[[f32;3]]` into a byte vec.
59fn positions_to_bytes(positions: &[[f32; 3]]) -> Vec<u8> {
60    let mut buf = Vec::with_capacity(positions.len() * 12);
61    for p in positions {
62        for &c in p {
63            buf.extend_from_slice(&c.to_le_bytes());
64        }
65    }
66    buf
67}
68
69/// Compute per-vertex position deltas: `morph[i] - base[i]`.
70fn compute_deltas(base: &[[f32; 3]], morph: &[[f32; 3]]) -> Vec<[f32; 3]> {
71    base.iter()
72        .zip(morph.iter())
73        .map(|(b, m)| [m[0] - b[0], m[1] - b[1], m[2] - b[2]])
74        .collect()
75}
76
77// ── main export ──────────────────────────────────────────────────────────────
78
79/// Export a GLTF animation as a standalone JSON string.
80///
81/// * `base_positions` — base mesh vertex positions.
82/// * `morph_target_positions` — per-target position delta arrays; each inner
83///   vec must have the same length as `base_positions`.
84/// * `clip` — the animation keyframes.
85///
86/// Returns a GLTF 2.0 JSON string with mesh + animation.
87pub fn export_animation_gltf(
88    base_positions: &[[f32; 3]],
89    morph_target_positions: &[Vec<[f32; 3]>],
90    clip: &AnimClip,
91) -> Result<String> {
92    let n_verts = base_positions.len();
93    let n_targets = morph_target_positions.len();
94
95    // Validate morph target vertex counts
96    for (i, target) in morph_target_positions.iter().enumerate() {
97        if target.len() != n_verts {
98            bail!(
99                "morph_target_positions[{}] has {} verts, expected {}",
100                i,
101                target.len(),
102                n_verts
103            );
104        }
105    }
106
107    // Validate keyframe weight counts
108    for (ki, kf) in clip.keyframes.iter().enumerate() {
109        if kf.weights.len() != n_targets {
110            bail!(
111                "keyframe[{}].weights has {} entries, expected {} (n_targets)",
112                ki,
113                kf.weights.len(),
114                n_targets
115            );
116        }
117    }
118
119    // ── Assemble binary buffer ────────────────────────────────────────────────
120    //
121    // Layout (all sections 4-byte aligned):
122    //   [0] BASE POSITION  (n_verts × 3 × f32)
123    //   [1..n_targets] DELTA POSITION per morph target
124    //   [last-2] times accessor  (n_keyframes × f32)      — only if keyframes > 0
125    //   [last-1] weights accessor (n_keyframes × n_targets × f32) — only if keyframes > 0
126
127    let mut buffer: Vec<u8> = Vec::new();
128
129    // Helper to record a section: returns (byte_offset, byte_length).
130    // Pads to 4-byte alignment after appending.
131    let append_section = |buffer: &mut Vec<u8>, data: &[u8]| -> (usize, usize) {
132        let offset = buffer.len();
133        let length = data.len();
134        buffer.extend_from_slice(data);
135        // pad to 4-byte alignment
136        while !buffer.len().is_multiple_of(4) {
137            buffer.push(0x00);
138        }
139        (offset, length)
140    };
141
142    // BASE POSITION
143    let base_bytes = positions_to_bytes(base_positions);
144    let (base_pos_offset, base_pos_len) = append_section(&mut buffer, &base_bytes);
145
146    // MORPH TARGET DELTAS
147    let mut delta_sections: Vec<(usize, usize)> = Vec::with_capacity(n_targets);
148    for target in morph_target_positions {
149        let deltas = compute_deltas(base_positions, target);
150        let delta_bytes = positions_to_bytes(&deltas);
151        let sec = append_section(&mut buffer, &delta_bytes);
152        delta_sections.push(sec);
153    }
154
155    // TIMES + WEIGHTS (only when clip has keyframes)
156    let has_animation = !clip.keyframes.is_empty();
157    let n_keyframes = clip.keyframes.len();
158
159    let times_section: (usize, usize);
160    let weights_section: (usize, usize);
161
162    if has_animation {
163        // times
164        let mut times_bytes: Vec<u8> = Vec::with_capacity(n_keyframes * 4);
165        for kf in &clip.keyframes {
166            push_f32_slice(&mut times_bytes, &[kf.time_s]);
167        }
168        times_section = append_section(&mut buffer, &times_bytes);
169
170        // weights — flattened: for each keyframe, all target weights in order
171        let total_weights = n_keyframes * n_targets;
172        let mut weights_bytes: Vec<u8> = Vec::with_capacity(total_weights * 4);
173        for kf in &clip.keyframes {
174            push_f32_slice(&mut weights_bytes, &kf.weights);
175        }
176        weights_section = append_section(&mut buffer, &weights_bytes);
177    } else {
178        times_section = (0, 0);
179        weights_section = (0, 0);
180    }
181
182    // ── Build accessor / bufferView index accounting ──────────────────────────
183    //
184    // Accessor indices:
185    //   0            → BASE POSITION
186    //   1..n_targets → DELTA POSITION per target
187    //   (if animation)
188    //   n_targets+1  → times
189    //   n_targets+2  → weights
190
191    let times_accessor_idx = (n_targets + 1) as u64;
192    let weights_accessor_idx = (n_targets + 2) as u64;
193
194    // bufferViews mirror accessors 1-to-1
195    let mut buffer_views: Vec<Value> = Vec::new();
196    let mut accessors: Vec<Value> = Vec::new();
197
198    // BASE POSITION accessor
199    buffer_views.push(json!({
200        "buffer": 0,
201        "byteOffset": base_pos_offset,
202        "byteLength": base_pos_len
203    }));
204    accessors.push(json!({
205        "bufferView": 0,
206        "componentType": 5126,   // FLOAT
207        "count": n_verts,
208        "type": "VEC3"
209    }));
210
211    // DELTA POSITION accessors
212    for (i, &(offset, length)) in delta_sections.iter().enumerate() {
213        let bv_idx = (i + 1) as u64;
214        buffer_views.push(json!({
215            "buffer": 0,
216            "byteOffset": offset,
217            "byteLength": length
218        }));
219        accessors.push(json!({
220            "bufferView": bv_idx,
221            "componentType": 5126,
222            "count": n_verts,
223            "type": "VEC3"
224        }));
225    }
226
227    // TIMES accessor
228    if has_animation {
229        let bv_times = (n_targets + 1) as u64;
230        let (t_offset, t_length) = times_section;
231        buffer_views.push(json!({
232            "buffer": 0,
233            "byteOffset": t_offset,
234            "byteLength": t_length
235        }));
236        accessors.push(json!({
237            "bufferView": bv_times,
238            "componentType": 5126,
239            "count": n_keyframes,
240            "type": "SCALAR"
241        }));
242
243        // WEIGHTS accessor
244        let bv_weights = (n_targets + 2) as u64;
245        let (w_offset, w_length) = weights_section;
246        buffer_views.push(json!({
247            "buffer": 0,
248            "byteOffset": w_offset,
249            "byteLength": w_length
250        }));
251        accessors.push(json!({
252            "bufferView": bv_weights,
253            "componentType": 5126,
254            "count": n_keyframes * n_targets,
255            "type": "SCALAR"
256        }));
257    }
258
259    // ── Mesh primitives targets ───────────────────────────────────────────────
260    let targets: Vec<Value> = (0..n_targets)
261        .map(|i| json!({ "POSITION": i + 1 }))
262        .collect();
263
264    let initial_weights: Vec<f32> = vec![0.0_f32; n_targets];
265
266    // ── Animation ────────────────────────────────────────────────────────────
267    let animations: Value = if has_animation {
268        json!([{
269            "name": clip.name,
270            "samplers": [{
271                "input": times_accessor_idx,
272                "output": weights_accessor_idx,
273                "interpolation": "LINEAR"
274            }],
275            "channels": [{
276                "sampler": 0,
277                "target": { "node": 0, "path": "weights" }
278            }]
279        }])
280    } else {
281        json!([])
282    };
283
284    // ── Buffer URI ────────────────────────────────────────────────────────────
285    let b64 = to_base64(&buffer);
286    let data_uri = format!("data:application/octet-stream;base64,{}", b64);
287
288    // ── Assemble GLTF ─────────────────────────────────────────────────────────
289    let gltf = json!({
290        "asset": { "version": "2.0", "generator": "OxiHuman 0.1.0" },
291        "scene": 0,
292        "scenes": [{ "nodes": [0] }],
293        "nodes": [{ "mesh": 0 }],
294        "meshes": [{
295            "name": clip.name,
296            "primitives": [{
297                "attributes": { "POSITION": 0 },
298                "mode": 4,
299                "targets": targets
300            }],
301            "weights": initial_weights
302        }],
303        "accessors": accessors,
304        "bufferViews": buffer_views,
305        "buffers": [{
306            "uri": data_uri,
307            "byteLength": buffer.len()
308        }],
309        "animations": animations
310    });
311
312    Ok(serde_json::to_string_pretty(&gltf)?)
313}
314
315// ── Tests ─────────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_json::Value;
321
322    /// Build a minimal base mesh and morph targets for testing.
323    fn make_base(n_verts: usize) -> Vec<[f32; 3]> {
324        (0..n_verts).map(|i| [i as f32, 0.0, 0.0]).collect()
325    }
326
327    fn make_target(base: &[[f32; 3]], offset: f32) -> Vec<[f32; 3]> {
328        base.iter().map(|&[x, y, z]| [x + offset, y, z]).collect()
329    }
330
331    fn two_keyframe_clip(n_targets: usize) -> AnimClip {
332        AnimClip {
333            name: "Test".to_string(),
334            keyframes: vec![
335                AnimKeyframe {
336                    time_s: 0.0,
337                    weights: vec![0.0; n_targets],
338                },
339                AnimKeyframe {
340                    time_s: 1.0,
341                    weights: vec![1.0; n_targets],
342                },
343            ],
344        }
345    }
346
347    // ── Test 1 ────────────────────────────────────────────────────────────────
348    /// Exported JSON must parse and have asset.version == "2.0".
349    #[test]
350    fn animation_json_parses() {
351        let base = make_base(4);
352        let targets = vec![make_target(&base, 0.1)];
353        let clip = two_keyframe_clip(1);
354        let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
355        let val: Value = serde_json::from_str(&json_str).expect("must parse as JSON");
356        assert_eq!(val["asset"]["version"].as_str().expect("should succeed"), "2.0");
357    }
358
359    // ── Test 2 ────────────────────────────────────────────────────────────────
360    /// 3 morph targets → meshes[0].primitives[0].targets has 3 entries.
361    #[test]
362    fn animation_has_correct_target_count() {
363        let base = make_base(5);
364        let targets: Vec<Vec<[f32; 3]>> =
365            (0..3).map(|i| make_target(&base, i as f32 * 0.1)).collect();
366        let clip = two_keyframe_clip(3);
367        let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
368        let val: Value = serde_json::from_str(&json_str).expect("should succeed");
369        let tgt_arr = val["meshes"][0]["primitives"][0]["targets"]
370            .as_array()
371            .expect("should succeed");
372        assert_eq!(tgt_arr.len(), 3);
373    }
374
375    // ── Test 3 ────────────────────────────────────────────────────────────────
376    /// 4 keyframes, 2 targets → weights accessor count = 4 * 2 = 8.
377    #[test]
378    fn animation_keyframe_count_matches() {
379        let n_targets = 2usize;
380        let n_keyframes = 4usize;
381        let base = make_base(3);
382        let targets: Vec<Vec<[f32; 3]>> = (0..n_targets)
383            .map(|i| make_target(&base, i as f32 * 0.05))
384            .collect();
385        let clip = AnimClip {
386            name: "Walk".to_string(),
387            keyframes: (0..n_keyframes)
388                .map(|k| AnimKeyframe {
389                    time_s: k as f32 * 0.25,
390                    weights: vec![k as f32 / n_keyframes as f32; n_targets],
391                })
392                .collect(),
393        };
394        let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
395        let val: Value = serde_json::from_str(&json_str).expect("should succeed");
396
397        // The weights accessor is the last one.
398        let accessors = val["accessors"].as_array().expect("should succeed");
399        let weights_acc = accessors.last().expect("should succeed");
400        assert_eq!(
401            weights_acc["count"].as_u64().expect("should succeed"),
402            (n_keyframes * n_targets) as u64
403        );
404    }
405
406    // ── Test 4 ────────────────────────────────────────────────────────────────
407    /// Empty clip → animations array is empty.
408    #[test]
409    fn empty_clip_no_animation() {
410        let base = make_base(4);
411        let targets = vec![make_target(&base, 0.1)];
412        let clip = AnimClip {
413            name: "Empty".to_string(),
414            keyframes: vec![],
415        };
416        let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
417        let val: Value = serde_json::from_str(&json_str).expect("should succeed");
418        let anim = val["animations"].as_array().expect("should succeed");
419        assert!(anim.is_empty(), "animations should be empty for empty clip");
420    }
421
422    // ── Test 5 ────────────────────────────────────────────────────────────────
423    /// to_base64 on known input matches expected base64 string.
424    #[test]
425    fn base64_roundtrip() {
426        // "Man" → base64 "TWFu"
427        assert_eq!(to_base64(b"Man"), "TWFu");
428        // "Ma" → "TWE="
429        assert_eq!(to_base64(b"Ma"), "TWE=");
430        // "M" → "TQ=="
431        assert_eq!(to_base64(b"M"), "TQ==");
432        // empty
433        assert_eq!(to_base64(b""), "");
434        // longer known value: "Hello" → "SGVsbG8="
435        assert_eq!(to_base64(b"Hello"), "SGVsbG8=");
436    }
437
438    // ── Test 6 ────────────────────────────────────────────────────────────────
439    /// Weights accessor binary data matches expected byte layout.
440    ///
441    /// We construct a 1-target, 2-keyframe clip with weights [0.25] and [0.75],
442    /// decode the base64 buffer from the JSON, and check the bytes at the
443    /// weights accessor offset match the expected f32 little-endian layout.
444    #[test]
445    fn weights_flattened_order() {
446        let base = make_base(2);
447        let targets = vec![make_target(&base, 0.1)];
448        let clip = AnimClip {
449            name: "Weights".to_string(),
450            keyframes: vec![
451                AnimKeyframe {
452                    time_s: 0.0,
453                    weights: vec![0.25_f32],
454                },
455                AnimKeyframe {
456                    time_s: 1.0,
457                    weights: vec![0.75_f32],
458                },
459            ],
460        };
461
462        let json_str = export_animation_gltf(&base, &targets, &clip).expect("should succeed");
463        let val: Value = serde_json::from_str(&json_str).expect("should succeed");
464
465        // Extract buffer URI and decode base64
466        let uri = val["buffers"][0]["uri"].as_str().expect("should succeed");
467        let b64_data = uri
468            .strip_prefix("data:application/octet-stream;base64,")
469            .expect("should succeed");
470        let raw = decode_base64(b64_data);
471
472        // Find weights bufferView (last bufferView)
473        let bvs = val["bufferViews"].as_array().expect("should succeed");
474        let weights_bv = bvs.last().expect("should succeed");
475        let offset = weights_bv["byteOffset"].as_u64().expect("should succeed") as usize;
476        let length = weights_bv["byteLength"].as_u64().expect("should succeed") as usize;
477
478        let weights_bytes = &raw[offset..offset + length];
479
480        // Expected: [0.25f32, 0.75f32] in LE
481        let mut expected = Vec::new();
482        expected.extend_from_slice(&0.25_f32.to_le_bytes());
483        expected.extend_from_slice(&0.75_f32.to_le_bytes());
484
485        assert_eq!(weights_bytes, expected.as_slice());
486    }
487
488    /// Minimal base64 decoder for test verification only.
489    fn decode_base64(s: &str) -> Vec<u8> {
490        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
491        let mut out = Vec::new();
492        let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
493        let lookup = |c: u8| {
494            CHARS
495                .iter()
496                .position(|&x| x == c)
497                .expect("invalid base64 character") as u32
498        };
499        let mut i = 0;
500        // Count actual padding
501        let pad = s.bytes().filter(|&b| b == b'=').count();
502        let chunks_len = bytes.len().div_ceil(4);
503        while i < chunks_len {
504            let start = i * 4;
505            let get = |j: usize| {
506                if start + j < bytes.len() {
507                    lookup(bytes[start + j])
508                } else {
509                    0
510                }
511            };
512            let combined = (get(0) << 18) | (get(1) << 12) | (get(2) << 6) | get(3);
513            out.push(((combined >> 16) & 0xFF) as u8);
514            if !(i == chunks_len - 1 && pad >= 2) {
515                out.push(((combined >> 8) & 0xFF) as u8);
516            }
517            if !(i == chunks_len - 1 && pad >= 1) {
518                out.push((combined & 0xFF) as u8);
519            }
520            i += 1;
521        }
522        out
523    }
524}