From f468d148bc4aa57c5f2de721e1c35ffb0d767d9f Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Tue, 21 Jul 2026 09:52:06 -0700 Subject: [PATCH] feat(profiling): add dance motion profiler + beat-energy profiles for adopted dances tools/dance_profile.py samples world-space bone positions per frame in Blender headless and emits a per-beat motion-energy timeline (accents, phrases, totals) at a given BPM. Profiles generated for war_dance_01, fertility_dance_01, taming_dance_01. Co-Authored-By: Claude Fable 5 --- .../profiles/fertility_dance_01-profile.json | 193 +++++++++ .../profiles/taming_dance_01-profile.json | 200 +++++++++ .../dances/profiles/war_dance_01-profile.json | 208 +++++++++ tools/dance_profile.py | 401 ++++++++++++++++++ 4 files changed, 1002 insertions(+) create mode 100644 docs/dances/profiles/fertility_dance_01-profile.json create mode 100644 docs/dances/profiles/taming_dance_01-profile.json create mode 100644 docs/dances/profiles/war_dance_01-profile.json create mode 100644 tools/dance_profile.py diff --git a/docs/dances/profiles/fertility_dance_01-profile.json b/docs/dances/profiles/fertility_dance_01-profile.json new file mode 100644 index 0000000..8bcb256 --- /dev/null +++ b/docs/dances/profiles/fertility_dance_01-profile.json @@ -0,0 +1,193 @@ +{ + "name": "fertility_dance_01", + "glb": "exchange/converted-glb/fertility_dance_01.glb", + "duration_s": 30.0, + "fps": 24, + "frames": 720, + "bpm": 100, + "beats": 50, + "energy_per_beat": [ + 0.0671, + 0.1187, + 0.066, + 0.0813, + 0.1161, + 0.234, + 0.3293, + 0.4352, + 0.4132, + 0.3849, + 0.4206, + 0.4606, + 0.4989, + 0.4251, + 0.4806, + 0.4737, + 0.5986, + 0.5101, + 0.2858, + 0.6089, + 0.5596, + 0.6887, + 0.8083, + 0.5382, + 0.4154, + 0.9773, + 1.034, + 0.5643, + 1.1262, + 0.4652, + 0.4643, + 0.9822, + 1.2955, + 0.6826, + 0.9628, + 1.3345, + 0.9006, + 0.5145, + 0.6956, + 0.6763, + 0.3807, + 0.4143, + 0.4418, + 0.489, + 0.5304, + 0.6733, + 0.5457, + 0.7964, + 0.5111, + 0.3799 + ], + "energy_norm": [ + 0.0009, + 0.0415, + 0.0, + 0.012, + 0.0395, + 0.1325, + 0.2075, + 0.2911, + 0.2737, + 0.2514, + 0.2795, + 0.3111, + 0.3413, + 0.2831, + 0.3269, + 0.3214, + 0.4199, + 0.3501, + 0.1733, + 0.428, + 0.3891, + 0.4909, + 0.5852, + 0.3722, + 0.2755, + 0.7184, + 0.7631, + 0.3928, + 0.8358, + 0.3147, + 0.314, + 0.7223, + 0.9693, + 0.4861, + 0.707, + 1.0, + 0.6579, + 0.3535, + 0.4963, + 0.4811, + 0.248, + 0.2745, + 0.2962, + 0.3335, + 0.3661, + 0.4788, + 0.3782, + 0.5758, + 0.3509, + 0.2475 + ], + "accent_beats": [ + 6, + 10, + 11, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 38, + 39, + 40, + 44, + 45, + 47 + ], + "pelvis_drop_beats": [ + 6, + 10, + 11, + 23, + 24, + 25, + 26, + 28, + 29, + 30, + 31, + 34, + 39, + 40, + 44, + 47 + ], + "hand_peak_beats": [ + 19, + 20, + 22, + 25, + 26, + 28, + 32, + 33, + 34, + 35, + 36, + 38, + 39, + 45, + 47 + ], + "phrase_beats": 3, + "totals": { + "hands_m": 26.653432767023645, + "forearms_m": 21.23157808829689, + "upper_arms_m": 13.863015059740327, + "feet_m": 9.769735456958077, + "shins_m": 13.50568795659558, + "pelvis_m": 10.606148213798214, + "head_m": 11.129225506410268, + "lr_symmetry_hands": 0.9986419895043533, + "lr_symmetry_feet": 0.9833129850891342, + "pelvis_vertical_range_m": 0.41678035259246826, + "footprint_m": [ + 0.536299467086792, + 0.8529399633407593 + ] + }, + "warnings": [] +} \ No newline at end of file diff --git a/docs/dances/profiles/taming_dance_01-profile.json b/docs/dances/profiles/taming_dance_01-profile.json new file mode 100644 index 0000000..b08dff9 --- /dev/null +++ b/docs/dances/profiles/taming_dance_01-profile.json @@ -0,0 +1,200 @@ +{ + "name": "taming_dance_01", + "glb": "exchange/converted-glb/taming_dance_01.glb", + "duration_s": 30.0, + "fps": 24, + "frames": 720, + "bpm": 100, + "beats": 50, + "energy_per_beat": [ + 0.9916, + 0.7155, + 0.7886, + 1.0957, + 2.4134, + 1.3599, + 1.4711, + 0.5712, + 0.885, + 0.9835, + 2.12, + 0.73, + 0.3254, + 0.9716, + 1.4584, + 0.8395, + 0.368, + 1.2858, + 0.6909, + 1.0359, + 1.7734, + 0.7137, + 0.976, + 1.7173, + 1.3732, + 0.6937, + 0.444, + 0.9441, + 0.9548, + 0.8954, + 1.6452, + 0.5804, + 0.2176, + 0.3835, + 0.9297, + 0.45, + 0.4495, + 1.0133, + 0.8501, + 0.9266, + 1.2257, + 1.0999, + 0.338, + 0.7127, + 1.1935, + 0.3326, + 0.7522, + 1.4484, + 2.0448, + 2.5482 + ], + "energy_norm": [ + 0.3321, + 0.2137, + 0.245, + 0.3768, + 0.9422, + 0.4901, + 0.5378, + 0.1517, + 0.2864, + 0.3287, + 0.8162, + 0.2199, + 0.0463, + 0.3235, + 0.5324, + 0.2669, + 0.0646, + 0.4584, + 0.2031, + 0.3511, + 0.6676, + 0.2129, + 0.3254, + 0.6435, + 0.4959, + 0.2043, + 0.0972, + 0.3117, + 0.3163, + 0.2908, + 0.6126, + 0.1557, + 0.0, + 0.0712, + 0.3055, + 0.0998, + 0.0995, + 0.3414, + 0.2714, + 0.3042, + 0.4326, + 0.3786, + 0.0517, + 0.2124, + 0.4187, + 0.0494, + 0.2294, + 0.5281, + 0.784, + 1.0 + ], + "accent_beats": [ + 0, + 1, + 3, + 4, + 6, + 9, + 10, + 11, + 13, + 14, + 15, + 17, + 19, + 20, + 22, + 23, + 24, + 27, + 34, + 36, + 37, + 40, + 41, + 43, + 44, + 47, + 48, + 49 + ], + "pelvis_drop_beats": [ + 1, + 11, + 14, + 15, + 22, + 34, + 36, + 37, + 48, + 49 + ], + "hand_peak_beats": [ + 0, + 1, + 3, + 4, + 6, + 9, + 10, + 13, + 15, + 17, + 19, + 20, + 22, + 23, + 24, + 27, + 34, + 37, + 40, + 41, + 43, + 44, + 47, + 48, + 49 + ], + "phrase_beats": 10, + "totals": { + "hands_m": 49.32286386602802, + "forearms_m": 32.85868643786276, + "upper_arms_m": 20.531188024591692, + "feet_m": 18.62118179928407, + "shins_m": 25.7590113640975, + "pelvis_m": 16.053067387300725, + "head_m": 17.234514413654498, + "lr_symmetry_hands": 0.9871641314548004, + "lr_symmetry_feet": 0.8839844668246384, + "pelvis_vertical_range_m": 0.9356732368469238, + "footprint_m": [ + 1.2194406312191859, + 1.6680276617407799 + ] + }, + "warnings": [] +} \ No newline at end of file diff --git a/docs/dances/profiles/war_dance_01-profile.json b/docs/dances/profiles/war_dance_01-profile.json new file mode 100644 index 0000000..a48aa01 --- /dev/null +++ b/docs/dances/profiles/war_dance_01-profile.json @@ -0,0 +1,208 @@ +{ + "name": "war_dance_01", + "glb": "exchange/converted-glb/war_dance_01.glb", + "duration_s": 30.0, + "fps": 24, + "frames": 720, + "bpm": 100, + "beats": 50, + "energy_per_beat": [ + 0.1161, + 0.0602, + 0.0763, + 0.0628, + 0.6339, + 0.7965, + 0.8297, + 0.6782, + 0.1496, + 0.0932, + 0.1334, + 0.8858, + 0.9878, + 0.8822, + 0.2595, + 0.1081, + 0.1336, + 0.2622, + 0.2088, + 0.232, + 0.1623, + 0.1525, + 0.1841, + 0.4061, + 0.4191, + 0.4014, + 0.3346, + 0.3014, + 0.6334, + 0.4097, + 0.6412, + 0.3384, + 0.1109, + 0.5587, + 0.2789, + 0.132, + 0.588, + 0.2621, + 0.5738, + 0.399, + 0.4924, + 0.4509, + 0.6284, + 0.3405, + 0.0878, + 0.0258, + 0.026, + 0.0, + 0.0, + 0.0 + ], + "energy_norm": [ + 0.1176, + 0.0609, + 0.0772, + 0.0635, + 0.6418, + 0.8063, + 0.84, + 0.6866, + 0.1514, + 0.0944, + 0.1351, + 0.8967, + 1.0, + 0.8931, + 0.2627, + 0.1094, + 0.1352, + 0.2654, + 0.2114, + 0.2348, + 0.1643, + 0.1544, + 0.1863, + 0.4111, + 0.4242, + 0.4064, + 0.3387, + 0.3051, + 0.6413, + 0.4147, + 0.6491, + 0.3426, + 0.1122, + 0.5656, + 0.2823, + 0.1336, + 0.5952, + 0.2654, + 0.5809, + 0.404, + 0.4985, + 0.4564, + 0.6361, + 0.3447, + 0.0889, + 0.0261, + 0.0263, + 0.0, + 0.0, + 0.0 + ], + "accent_beats": [ + 4, + 5, + 6, + 7, + 11, + 12, + 13, + 17, + 20, + 22, + 23, + 25, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 44, + 46, + 48, + 49 + ], + "pelvis_drop_beats": [ + 6, + 11, + 13, + 17, + 20, + 22, + 23, + 25, + 26, + 28, + 29, + 31, + 32, + 34, + 35, + 37, + 38, + 40, + 41, + 42, + 44, + 46, + 48, + 49 + ], + "hand_peak_beats": [ + 4, + 5, + 6, + 7, + 11, + 12, + 13, + 28, + 30, + 33, + 36, + 38, + 39, + 40, + 42 + ], + "phrase_beats": null, + "totals": { + "hands_m": 21.006407802644276, + "forearms_m": 12.905461711283007, + "upper_arms_m": 6.91306398100012, + "feet_m": 1.521721729225423, + "shins_m": 5.302736997519221, + "pelvis_m": 5.633388137876223, + "head_m": 6.2322590229562564, + "lr_symmetry_hands": 0.9744965493013217, + "lr_symmetry_feet": 0.6518710884905415, + "pelvis_vertical_range_m": 0.19754374027252197, + "footprint_m": [ + 0.2731027752161026, + 0.1643172651529312 + ] + }, + "warnings": [] +} \ No newline at end of file diff --git a/tools/dance_profile.py b/tools/dance_profile.py new file mode 100644 index 0000000..b3b8ed3 --- /dev/null +++ b/tools/dance_profile.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python +# dance_profile.py — Blender-headless dance motion profiler. +# +# Samples world-space pose-bone positions every frame of a dance clip's GLB, then +# derives a per-beat motion-energy timeline + accent / phrase / totals metrics. The +# method generalises the one-off analysis behind docs/dances/dance1.md (which sampled +# world-space bone positions per frame) into a reusable script that ALSO emits a beat +# timeline at a given musical BPM. +# +# Usage (Blender bundles Python + numpy — no pip installs): +# /Applications/Blender.app/Contents/MacOS/Blender --background \ +# --python tools/dance_profile.py -- --glb --bpm 100 --out +# +# Output JSON schema: see docs/dances/dance1.md / task spec. Units are METERS (glTF is +# always meters; the retargeted Quaternius rigs bake object transform so Blender world +# == data space, Z-up). Pair groups (hands, forearms, ...) report the MEAN of the two +# sides' path lengths — this reproduces dance1.md ("hands ~49-50 m each") and the +# schema example (hands_m: 49.5); the pair sum would be ~2x. + +import bpy +import sys +import os +import json +import numpy as np + + +# --------------------------------------------------------------------------- +# Bone name resolution +# +# Rigs in this repo are Quaternius UE-named (pelvis / Head / hand_l / lowerarm_l / +# calf_l ...). We also tolerate CC_Base_* (CC_Base_L_Hand / CC_Base_L_Calf ...) and +# Mixamo B-* (B-hand.L / B-shin.L ...) so the same script works on pre-retarget +# source rigs. Matching is case-insensitive "contains", first candidate wins. +# --------------------------------------------------------------------------- + +# Center-line bones (no L/R). +CENTER_CANDIDATES = { + "pelvis": ["pelvis", "hips", "hip"], + "head": ["head"], +} + +# Paired bones: group -> (left_candidates, right_candidates). +SIDE_CANDIDATES = { + "hand": (["hand_l", "hand.l", "l_hand", "cc_base_l_hand", "b-hand.l", "hand_left"], + ["hand_r", "hand.r", "r_hand", "cc_base_r_hand", "b-hand.r", "hand_right"]), + "forearm": (["lowerarm_l", "lowerarm.l", "forearm_l", "forearm.l", "l_forearm", + "cc_base_l_forearm", "b-forearm.l", "lowerarm_left"], + ["lowerarm_r", "lowerarm.r", "forearm_r", "forearm.r", "r_forearm", + "cc_base_r_forearm", "b-forearm.r", "lowerarm_right"]), + "upperarm": (["upperarm_l", "upperarm.l", "l_upperarm", "cc_base_l_upperarm", + "b-upperarm.l", "upperarm_left"], + ["upperarm_r", "upperarm.r", "r_upperarm", "cc_base_r_upperarm", + "b-upperarm.r", "upperarm_right"]), + "foot": (["foot_l", "foot.l", "l_foot", "cc_base_l_foot", "b-foot.l", "foot_left"], + ["foot_r", "foot.r", "r_foot", "cc_base_r_foot", "b-foot.r", "foot_right"]), + "shin": (["calf_l", "calf.l", "shin_l", "shin.l", "l_calf", "cc_base_l_calf", + "b-shin.l", "b-calf.l", "calf_left", "shin_left"], + ["calf_r", "calf.r", "shin_r", "shin.r", "r_calf", "cc_base_r_calf", + "b-shin.r", "b-calf.r", "calf_right", "shin_right"]), +} + +# All logical group keys the metrics expect. +ALL_GROUPS = ["pelvis", "head", + "hand_l", "hand_r", "forearm_l", "forearm_r", + "upperarm_l", "upperarm_r", "foot_l", "foot_r", + "shin_l", "shin_r"] + + +def resolve_by_candidates(pose_bone_names, candidates): + """Return the first pose-bone name whose lowercased form contains any candidate + (candidates tried in priority order). None if nothing matches.""" + lowered = [(n, n.lower()) for n in pose_bone_names] + for cand in candidates: + for name, low in lowered: + if cand in low: + return name + return None + + +def resolve_bones(armature, warnings): + """Map every logical group to a pose-bone name (or None). Appends a warning per + missing group.""" + pose_names = [pb.name for pb in armature.pose.bones] + resolved = {} + + for group, cands in CENTER_CANDIDATES.items(): + resolved[group] = resolve_by_candidates(pose_names, cands) + + for group, (left_c, right_c) in SIDE_CANDIDATES.items(): + resolved[f"{group}_l"] = resolve_by_candidates(pose_names, left_c) + resolved[f"{group}_r"] = resolve_by_candidates(pose_names, right_c) + + for g in ALL_GROUPS: + if resolved.get(g) is None: + warnings.append(f"bone group not found: {g}") + return resolved + + +# --------------------------------------------------------------------------- +# Scene / action setup +# --------------------------------------------------------------------------- + +def parse_argv(): + """Args after the `--` separator that Blender leaves for the script.""" + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + args = {} + i = 0 + while i < len(argv): + key = argv[i] + if key.startswith("--"): + args[key] = argv[i + 1] if i + 1 < len(argv) else "" + i += 2 + else: + i += 1 + return args + + +def find_armature(): + arms = [o for o in bpy.data.objects if o.type == "ARMATURE"] + if not arms: + raise RuntimeError("no armature object in imported GLB") + if len(arms) > 1: + raise RuntimeError(f"expected exactly one armature, found {len(arms)}: " + f"{[a.name for a in arms]}") + return arms[0] + + +def get_action(armature): + """Active action on the armature, else the first action in bpy.data.actions.""" + ad = armature.animation_data + if ad and ad.action: + return ad.action + if bpy.data.actions: + a = bpy.data.actions[0] + armature.animation_data_create() + armature.animation_data.action = a + return a + raise RuntimeError("armature has no animation data and bpy.data.actions is empty") + + +# --------------------------------------------------------------------------- +# Sampling +# --------------------------------------------------------------------------- + +def sample_positions(armature, resolved, frame_list): + """World-space translation of every resolved bone across frame_list. + Returns {group: np.ndarray (N,3)} for resolved groups only.""" + scene = bpy.context.scene + mw = armature.matrix_world + groups = {g: name for g, name in resolved.items() if name is not None} + out = {g: np.empty((len(frame_list), 3), dtype=np.float64) for g in groups} + + for fi, f in enumerate(frame_list): + scene.frame_set(f) + for g, name in groups.items(): + pb = armature.pose.bones[name] + t = (mw @ pb.matrix).translation + out[g][fi, 0] = t.x + out[g][fi, 1] = t.y + out[g][fi, 2] = t.z + return out + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + +def path_length(pos): + d = np.diff(pos, axis=0) + return float(np.sum(np.linalg.norm(d, axis=1))) + + +def per_interval_speed(pos, fps): + """Speed (m/s) for each inter-frame interval. Length N-1.""" + d = np.diff(pos, axis=0) + return np.linalg.norm(d, axis=1) * fps + + +def local_min_indices(sig): + return [i for i in range(1, len(sig) - 1) + if sig[i] < sig[i - 1] and sig[i] < sig[i + 1]] + + +def local_max_indices(sig): + return [i for i in range(1, len(sig) - 1) + if sig[i] > sig[i - 1] and sig[i] > sig[i + 1]] + + +def pearson(a, b): + a = a - a.mean() + b = b - b.mean() + da = np.sqrt(np.dot(a, a)) + db = np.sqrt(np.dot(b, b)) + if da < 1e-12 or db < 1e-12: + return 0.0 + return float(np.dot(a, b) / (da * db)) + + +def compute_profile(name, glb_arg, positions, resolved, fps, frames, f0, f1, bpm, warnings): + N = frames # number of sampled frames + duration_s = frames / fps + beats = int(round(duration_s * bpm / 60.0)) + if beats < 1: + beats = 1 + fpb = frames / float(beats) # frames per beat (float) + + dt_index = lambda sample_idx: int(min(beats - 1, max(0, sample_idx / fpb))) + + # ---- energy_per_beat: mean world speed over hands+feet+pelvis -------------- + energy_groups = ["hand_l", "hand_r", "foot_l", "foot_r", "pelvis"] + energy_groups = [g for g in energy_groups if g in positions] + if not energy_groups: + warnings.append("no energy bones (hands/feet/pelvis) resolved — energy timeline zero") + + energy_per_beat = [] + for b in range(beats): + i_start = int(np.floor(b * fpb)) + i_end = int(np.floor((b + 1) * fpb)) + i_start = max(0, min(i_start, N - 1)) + i_end = max(i_start + 1, min(i_end, N - 1)) # need >=1 delta + window_s = (i_end - i_start) / fps + bone_speeds = [] + for g in energy_groups: + seg = positions[g][i_start:i_end + 1] + dist = path_length(seg) + bone_speeds.append(dist / window_s if window_s > 0 else 0.0) + energy_per_beat.append(float(np.mean(bone_speeds)) if bone_speeds else 0.0) + + emin, emax = float(min(energy_per_beat)), float(max(energy_per_beat)) + if emax - emin > 1e-12: + energy_norm = [(e - emin) / (emax - emin) for e in energy_per_beat] + else: + energy_norm = [0.0 for _ in energy_per_beat] + + # ---- accent beats ------------------------------------------------------- + pelvis_drop_beats = [] + if "pelvis" in positions: + pz = positions["pelvis"][:, 2] + mean_z, std_z = float(pz.mean()), float(pz.std()) + thresh_min = mean_z - 0.5 * std_z + for i in local_min_indices(pz): + if pz[i] < thresh_min: + pelvis_drop_beats.append(dt_index(i)) + else: + warnings.append("pelvis missing — pelvis-drop accents skipped") + pelvis_drop_beats = sorted(set(pelvis_drop_beats)) + + hand_peak_beats = [] + if "hand_l" in positions or "hand_r" in positions: + hs = np.zeros(max(0, N - 1), dtype=np.float64) + for g in ("hand_l", "hand_r"): + if g in positions: + hs = hs + per_interval_speed(positions[g], fps) + if len(hs) > 2: + mean_hs, std_hs = float(hs.mean()), float(hs.std()) + thresh_max = mean_hs + 1.0 * std_hs + for i in local_max_indices(hs): + if hs[i] > thresh_max: + hand_peak_beats.append(dt_index(i)) + else: + warnings.append("both hands missing — hand-peak accents skipped") + hand_peak_beats = sorted(set(hand_peak_beats)) + + accent_beats = sorted(set(pelvis_drop_beats) | set(hand_peak_beats)) + + # ---- phrase_beats: dominant period via autocorrelation of energy_norm ---- + en = np.array(energy_norm, dtype=np.float64) + best_lag, best_r = None, -1.0 + for k in range(2, max(2, beats // 2) + 1): + if k >= len(en): + break + r = pearson(en[:-k], en[k:]) + if r > best_r: + best_r, best_lag = r, k + phrase_beats = int(best_lag) if (best_lag is not None and best_r >= 0.3) else None + + # ---- totals ------------------------------------------------------------- + def pair_path_mean(g): + ls = path_length(positions[g + "_l"]) if (g + "_l") in positions else None + rs = path_length(positions[g + "_r"]) if (g + "_r") in positions else None + vals = [v for v in (ls, rs) if v is not None] + return float(np.mean(vals)) if vals else None + + def lr_symmetry(g): + ls = path_length(positions[g + "_l"]) if (g + "_l") in positions else 0.0 + rs = path_length(positions[g + "_r"]) if (g + "_r") in positions else 0.0 + if max(ls, rs) < 1e-9: + return 1.0 + return float(min(ls, rs) / max(ls, rs)) + + totals = { + "hands_m": pair_path_mean("hand"), + "forearms_m": pair_path_mean("forearm"), + "upper_arms_m": pair_path_mean("upperarm"), + "feet_m": pair_path_mean("foot"), + "shins_m": pair_path_mean("shin"), + "pelvis_m": path_length(positions["pelvis"]) if "pelvis" in positions else None, + "head_m": path_length(positions["head"]) if "head" in positions else None, + "lr_symmetry_hands": lr_symmetry("hand"), + "lr_symmetry_feet": lr_symmetry("foot"), + } + if "pelvis" in positions: + p = positions["pelvis"] + # Blender is Z-up: vertical range = Z; the horizontal footprint = X,Y. + # (The spec's "[x_range, z_range]" is glTF Y-up notation — glTF's Z maps to + # Blender Y on import — so this reproduces the schema's [1.2, 1.7] footing.) + totals["pelvis_vertical_range_m"] = float(p[:, 2].max() - p[:, 2].min()) + totals["footprint_m"] = [float(p[:, 0].max() - p[:, 0].min()), + float(p[:, 1].max() - p[:, 1].min())] + else: + totals["pelvis_vertical_range_m"] = None + totals["footprint_m"] = [None, None] + + profile = { + "name": name, + "glb": glb_arg, + "duration_s": round(duration_s, 4), + "fps": fps, + "frames": frames, + "bpm": bpm, + "beats": beats, + "energy_per_beat": [round(e, 4) for e in energy_per_beat], + "energy_norm": [round(e, 4) for e in energy_norm], + "accent_beats": accent_beats, + "pelvis_drop_beats": pelvis_drop_beats, + "hand_peak_beats": hand_peak_beats, + "phrase_beats": phrase_beats, + "totals": totals, + "warnings": warnings, + } + return profile + + +def print_summary(profile): + accent = profile["accent_beats"] + top = accent[:8] + print("[profile] %s : %.2fs, %d frames @ %d fps" + % (profile["name"], profile["duration_s"], profile["frames"], profile["fps"])) + print("[profile] %d beats @ %d bpm (energy mean %.2f, peak %.2f)" + % (profile["beats"], profile["bpm"], + float(np.mean(profile["energy_per_beat"])), + float(max(profile["energy_per_beat"])))) + print("[profile] accent beats (%d): %s%s" + % (len(accent), top, " ..." if len(accent) > 8 else "")) + print("[profile] pelvis-drops: %s | hand-peaks: %s | phrase: %s beats" + % (profile["pelvis_drop_beats"], profile["hand_peak_beats"], + profile["phrase_beats"] if profile["phrase_beats"] is not None else "null")) + w = profile["warnings"] + print("[profile] warnings (%d): %s" % (len(w), w if w else "none")) + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +def main(): + args = parse_argv() + glb_arg = args.get("--glb") + bpm = int(args.get("--bpm", "100")) + out = args.get("--out") + if not glb_arg or not out: + raise SystemExit("usage: dance_profile.py -- --glb --bpm --out ") + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=glb_arg) + bpy.context.view_layer.update() + + armature = find_armature() + action = get_action(armature) + f0, f1 = action.frame_range + f0, f1 = int(f0), int(f1) + # Scene fps from the imported scene (glTF carries no per-action fps metadata; + # the importer bakes the clip at the scene rate, so frames/fps == source duration). + fps = bpy.context.scene.render.fps + + warnings = [] + resolved = resolve_bones(armature, warnings) + frame_list = list(range(f0, f1 + 1)) + frames = len(frame_list) + + positions = sample_positions(armature, resolved, frame_list) + + name = os.path.splitext(os.path.basename(glb_arg))[0] + profile = compute_profile(name, glb_arg, positions, resolved, fps, frames, + f0, f1, bpm, warnings) + + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + with open(out, "w") as fh: + json.dump(profile, fh, indent=2) + + # Sanity line so we can confirm units are meters on the first run. + if "pelvis" in positions: + pz = positions["pelvis"][:, 2] + print("[profile] pelvis z: mean %.3f m, range %.3f m (units check)" + % (float(pz.mean()), float(pz.max() - pz.min()))) + print_summary(profile) + print("[profile] WROTE %s" % out) + + +if __name__ == "__main__": + main()