Files
2026-01-02 01:47:19 -08:00

2440 lines
120 KiB
Python

import torch
import numpy as np
import math
import os
import json
import random
import tarfile
import urllib.request
import shutil
from typing import List, Tuple, Dict, Optional
# CORRECTED IMPORT: Looks inside the render_3d folder
from .render_3d.taichi_cylinder import render_whole
# ============================================================================
# SKELETON DEFINITIONS
# ============================================================================
JOINT_NAMES = [
"nose", "neck", "r_shoulder", "r_elbow", "r_wrist", "l_shoulder",
"l_elbow", "l_wrist", "r_hip", "r_knee", "r_ankle", "l_hip",
"l_knee", "l_ankle", "r_eye", "l_eye", "r_ear", "l_ear",
]
LIMB_SEQ = [
(1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10),
(1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
]
JOINT_PARENTS = {
0: 1, 1: -1, 2: 1, 3: 2, 4: 3, 5: 1, 6: 5, 7: 6, 8: 1, 9: 8, 10: 9,
11: 1, 12: 11, 13: 12, 14: 0, 15: 0, 16: 14, 17: 15,
}
BONE_COLORS = [
[1.0, 0.15, 0.15, 0.8], [0.15, 1.0, 1.0, 0.8], [1.0, 0.43, 0.15, 0.8],
[1.0, 0.72, 0.15, 0.8], [0.15, 0.72, 1.0, 0.8], [0.15, 0.43, 1.0, 0.8],
[0.75, 1.0, 0.15, 0.8], [0.15, 1.0, 0.15, 0.8], [0.15, 1.0, 0.43, 0.8],
[0.15, 0.15, 1.0, 0.8], [0.43, 0.15, 1.0, 0.8], [0.72, 0.15, 1.0, 0.8],
[0.65, 0.65, 0.65, 0.8], [1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
[1.0, 0.15, 0.72, 0.8], [0.32, 0.15, 1.0, 0.8],
]
# ============================================================================
# AUDIO FEATURE EXTRACTION
# ============================================================================
class SCAILAudioFeatureExtractor:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO", {"tooltip": "Audio input from LoadAudio or other audio node"}),
"frame_count": ("INT", {"default": 81, "min": 1, "max": 10000, "tooltip": "Number of output frames. Should match your video length."}),
"fps": ("INT", {"default": 24, "min": 1, "max": 120, "tooltip": "Frames per second. Match your target video FPS."}),
"bass_range": ("STRING", {"default": "20-250", "tooltip": "Bass frequency range in Hz (low-high). Drives body motion."}),
"mid_range": ("STRING", {"default": "250-2000", "tooltip": "Mid frequency range in Hz. Vocals/melody typically here."}),
"treble_range": ("STRING", {"default": "2000-8000", "tooltip": "Treble frequency range in Hz. Hi-hats, cymbals, detail."}),
"smoothing": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "tooltip": "Temporal smoothing. Higher = less jittery but less reactive."}),
}
}
RETURN_TYPES = ("SCAIL_AUDIO_FEATURES",)
RETURN_NAMES = ("audio_features",)
FUNCTION = "extract"
CATEGORY = "SCAIL-AudioReactive"
def extract(self, audio, frame_count, fps, bass_range, mid_range, treble_range, smoothing):
def parse_range(s):
low, high = s.split("-")
return int(low), int(high)
bass_low, bass_high = parse_range(bass_range)
mid_low, mid_high = parse_range(mid_range)
treble_low, treble_high = parse_range(treble_range)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if waveform.dim() == 3: waveform = waveform[0]
if waveform.shape[0] > 1: waveform = waveform.mean(dim=0)
else: waveform = waveform[0]
waveform = waveform.numpy()
frame_duration = 1.0 / fps
samples_per_frame = int(sample_rate * frame_duration)
rms = np.zeros(frame_count)
bass = np.zeros(frame_count)
mid = np.zeros(frame_count)
treble = np.zeros(frame_count)
onsets = np.zeros(frame_count)
for i in range(frame_count):
start = int(i * samples_per_frame)
end = min(start + samples_per_frame, len(waveform))
if start >= len(waveform): break
frame_audio = waveform[start:end]
if len(frame_audio) == 0: continue
rms[i] = np.sqrt(np.mean(frame_audio ** 2))
if len(frame_audio) > 1:
fft = np.fft.rfft(frame_audio)
freqs = np.fft.rfftfreq(len(frame_audio), 1.0 / sample_rate)
mag = np.abs(fft)
bass[i] = np.mean(mag[(freqs >= bass_low) & (freqs < bass_high)]) if np.any((freqs >= bass_low) & (freqs < bass_high)) else 0
mid[i] = np.mean(mag[(freqs >= mid_low) & (freqs < mid_high)]) if np.any((freqs >= mid_low) & (freqs < mid_high)) else 0
treble[i] = np.mean(mag[(freqs >= treble_low) & (freqs < treble_high)]) if np.any((freqs >= treble_low) & (freqs < treble_high)) else 0
rms_diff = np.diff(rms, prepend=rms[0])
onsets = np.maximum(rms_diff, 0)
def normalize(arr):
mx, mn = arr.max(), arr.min()
return (arr - mn) / (mx - mn) if mx > mn else np.zeros_like(arr)
rms = normalize(rms)
bass = normalize(bass)
mid = normalize(mid)
treble = normalize(treble)
onsets = normalize(onsets)
if smoothing > 0:
alpha = 1.0 - smoothing
for arr in [rms, bass, mid, treble]:
for i in range(1, len(arr)):
arr[i] = alpha * arr[i] + (1 - alpha) * arr[i-1]
return ({
"rms": torch.from_numpy(rms).float(),
"bass": torch.from_numpy(bass).float(),
"mid": torch.from_numpy(mid).float(),
"treble": torch.from_numpy(treble).float(),
"onsets": torch.from_numpy(onsets).float(),
"frame_count": frame_count,
"fps": fps,
},)
# ============================================================================
# BASE POSE GENERATION (PROCEDURAL)
# ============================================================================
class SCAILBasePoseGenerator:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pose_type": (["active_idle", "t_pose", "a_pose", "custom"], {"default": "active_idle", "tooltip": "Starting pose style. active_idle=relaxed dance-ready"}),
"character_count": ("INT", {"default": 1, "min": 1, "max": 5, "tooltip": "Number of characters to generate"}),
"spacing": ("FLOAT", {"default": 150.0, "min": 50.0, "max": 500.0, "tooltip": "Horizontal distance between characters"}),
"height": ("FLOAT", {"default": 400.0, "tooltip": "Character height scaling. 400 is standard."}),
"depth": ("FLOAT", {"default": 800.0, "tooltip": "Z-depth from camera. Higher = further away."}),
"center_x": ("FLOAT", {"default": 0.0, "tooltip": "Horizontal offset from frame center"}),
"center_y": ("FLOAT", {"default": 0.0, "tooltip": "Vertical offset from frame center"}),
},
"optional": {"custom_pose": ("SCAIL_POSE", {"tooltip": "Override with custom pose when pose_type='custom'"})}
}
RETURN_TYPES = ("SCAIL_POSE",)
RETURN_NAMES = ("base_pose",)
FUNCTION = "generate"
CATEGORY = "SCAIL-AudioReactive"
def generate(self, pose_type, character_count, spacing, height, depth, center_x, center_y, custom_pose=None):
if pose_type == "custom" and custom_pose: return (custom_pose,)
scale = height / 400.0
base_joints_map = {
"neck": [0, -120, 0], "nose": [0, -160, -20],
"r_shoulder": [-60, -110, 0], "l_shoulder": [60, -110, 0],
"r_hip": [-35, 40, 0], "l_hip": [35, 40, 0],
"r_eye": [-15, -170, -25], "l_eye": [15, -170, -25],
"r_ear": [-35, -160, 5], "l_ear": [35, -160, 5],
}
if pose_type == "t_pose":
base_joints_map.update({
"r_elbow": [-130, -110, 0], "r_wrist": [-200, -110, 0],
"l_elbow": [130, -110, 0], "l_wrist": [200, -110, 0],
"r_knee": [-40, 120, 0], "r_ankle": [-40, 200, 0],
"l_knee": [40, 120, 0], "l_ankle": [40, 200, 0],
})
elif pose_type == "a_pose":
base_joints_map.update({
"r_elbow": [-110, -60, 0], "r_wrist": [-160, -10, 0],
"l_elbow": [110, -60, 0], "l_wrist": [160, -10, 0],
"r_knee": [-40, 120, 0], "r_ankle": [-40, 200, 0],
"l_knee": [40, 120, 0], "l_ankle": [40, 200, 0],
})
else: # active_idle
base_joints_map.update({
"r_elbow": [-90, -30, 20], "r_wrist": [-100, 50, 40],
"l_elbow": [90, -30, 20], "l_wrist": [100, 50, 40],
"r_knee": [-45, 110, 30], "r_ankle": [-40, 195, 0],
"l_knee": [45, 110, 30], "l_ankle": [40, 195, 0],
})
single_skeleton = np.zeros((18, 3), dtype=np.float32)
for name, coords in base_joints_map.items():
if name in JOINT_NAMES:
single_skeleton[JOINT_NAMES.index(name)] = coords
total_joints = []
full_limb_seq = []
full_colors = []
total_width = (character_count - 1) * spacing
start_x = -total_width / 2.0
for i in range(character_count):
char_joints = single_skeleton.copy()
char_joints *= scale
x_offset = start_x + (i * spacing)
char_joints[:, 0] += center_x + x_offset
char_joints[:, 1] += center_y
char_joints[:, 2] += depth
total_joints.append(char_joints)
index_offset = i * 18
for (s, e) in LIMB_SEQ:
full_limb_seq.append((s + index_offset, e + index_offset))
full_colors.extend(BONE_COLORS)
final_joints = np.concatenate(total_joints, axis=0) # Shape (18*N, 3)
return ({"joints": torch.from_numpy(final_joints).float(),
"limb_seq": full_limb_seq,
"bone_colors": full_colors,
"char_count": character_count},)
# ============================================================================
# BEAT DETECTION
# ============================================================================
class SCAILBeatDetector:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO", {"tooltip": "Audio input for beat analysis"}),
"frame_count": ("INT", {"default": 81, "min": 1, "max": 10000, "tooltip": "Number of frames. Should match your video length."}),
"fps": ("INT", {"default": 24, "min": 1, "max": 120, "tooltip": "Frames per second. Match your target video FPS."}),
}
}
RETURN_TYPES = ("SCAIL_BEAT_INFO",)
RETURN_NAMES = ("beat_info",)
FUNCTION = "detect"
CATEGORY = "SCAIL-AudioReactive"
def detect(self, audio, frame_count, fps):
import librosa
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if isinstance(waveform, torch.Tensor): waveform = waveform.numpy()
if waveform.ndim > 1: waveform = waveform.mean(axis=0) if waveform.shape[0] <= 2 else waveform[0]
waveform = waveform.flatten().astype(np.float32)
duration = frame_count / fps
tempo, beat_frames_audio = librosa.beat.beat_track(y=waveform, sr=sample_rate)
beat_frames = (librosa.frames_to_time(beat_frames_audio, sr=sample_rate) * fps).astype(int)
beat_frames = beat_frames[beat_frames < frame_count]
onset_env = librosa.onset.onset_strength(y=waveform, sr=sample_rate)
onset_resampled = np.interp(np.linspace(0, duration, frame_count), librosa.times_like(onset_env, sr=sample_rate), onset_env)
onset_resampled /= (onset_resampled.max() + 1e-8)
rms = librosa.feature.rms(y=waveform, hop_length=512)[0]
rms_resampled = np.interp(np.linspace(0, duration, frame_count), librosa.times_like(rms, sr=sample_rate), rms)
rms_resampled /= (rms_resampled.max() + 1e-8)
downbeats = beat_frames[::4] if len(beat_frames) >= 4 else beat_frames
tempo_val = float(tempo) if np.isscalar(tempo) else float(tempo[0])
print(f"[SCAIL] Beats: {len(beat_frames)}, Tempo: {tempo_val:.1f}")
return ({
"frame_count": frame_count, "fps": fps, "tempo": tempo_val,
"beat_frames": beat_frames, "downbeat_frames": downbeats,
"onset_envelope": torch.from_numpy(onset_resampled).float(),
"energy": torch.from_numpy(rms_resampled).float(),
},)
# ============================================================================
# MOTION SYSTEM (MATH)
# ============================================================================
def rotate_point(point, pivot, axis, angle):
p = point - pivot
c, s = np.cos(angle), np.sin(angle)
if axis == 0: # X (Pitch)
rot = np.array([p[0], p[1]*c - p[2]*s, p[1]*s + p[2]*c])
elif axis == 1: # Y (Yaw)
rot = np.array([p[0]*c + p[2]*s, p[1], -p[0]*s + p[2]*c])
else: # Z (Roll)
rot = np.array([p[0]*c - p[1]*s, p[0]*s + p[1]*c, p[2]])
return rot + pivot
def apply_chain_rotation(joints, pivot_idx, chain, axis, angle):
res = joints.copy()
pivot = joints[pivot_idx]
for idx in chain:
if idx != pivot_idx:
res[idx] = rotate_point(joints[idx], pivot, axis, angle)
return res
# ============================================================================
# EASING FUNCTIONS
# ============================================================================
def ease_linear(t):
"""Linear interpolation"""
return t
def ease_out(t):
"""Decelerate - fast start, slow end (good for hitting beats)"""
return 1.0 - (1.0 - t) ** 2
def ease_in_out(t):
"""Smooth acceleration and deceleration"""
return t * t * (3.0 - 2.0 * t)
def ease_bounce(t):
"""Bounce effect at the end"""
if t < 0.5:
return 2 * t * t
else:
t2 = t - 0.5
return 0.5 + t2 * (1.0 - t2) * 4
def ease_elastic(t):
"""Overshoot and settle back"""
if t == 0 or t == 1:
return t
p = 0.3
s = p / 4.0
t -= 1
return -(math.pow(2, 10 * t) * math.sin((t - s) * (2 * math.pi) / p)) + 1
def ease_punch(t):
"""Sharp hit then quick settle - great for dance hits"""
if t < 0.3:
return (t / 0.3) ** 0.5 # Fast attack
else:
return 1.0 - (1.0 - t) * 0.15 # Subtle settle
EASING_FUNCTIONS = {
"linear": ease_linear,
"ease_out": ease_out,
"ease_in_out": ease_in_out,
"bounce": ease_bounce,
"elastic": ease_elastic,
"punch": ease_punch,
}
DANCE_POSES = {
"neutral": {
"description": "Active Idle - Ready Stance",
"rotations": {
"spine": {"forward": 5},
"right_elbow": {"bend": 15}, "left_elbow": {"bend": 15},
"right_knee": {"bend": 15}, "left_knee": {"bend": 15},
}
},
"lean_left": {
"rotations": {"spine": {"tilt": 35}, "head": {"tilt": -30}, "right_knee": {"bend": 10}, "left_knee": {"bend": 45}, "right_shoulder": {"raise": 20}},
"translations": {"hips": {"x": -25, "y": 5}, "l_ankle": {"x": -25, "y": 0}}
},
"lean_right": {
"rotations": {"spine": {"tilt": -35}, "head": {"tilt": 30}, "left_knee": {"bend": 10}, "right_knee": {"bend": 45}, "left_shoulder": {"raise": 20}},
"translations": {"hips": {"x": 25, "y": 5}, "r_ankle": {"x": 25, "y": 0}}
},
"arms_up_open": {
"rotations": {"spine": {"forward": -15}, "head": {"nod": -20}, "right_shoulder": {"raise": 140, "forward": 20}, "left_shoulder": {"raise": 140, "forward": 20}, "right_elbow": {"bend": 40}, "left_elbow": {"bend": 40}, "right_knee": {"bend": 5}, "left_knee": {"bend": 5}},
"translations": {"hips": {"y": -10}}
},
"power_crouch": {
"rotations": {"spine": {"forward": 30}, "head": {"nod": -25}, "right_knee": {"bend": 45}, "left_knee": {"bend": 45}, "right_ankle": {"bend": 20}, "left_ankle": {"bend": 20}, "right_shoulder": {"forward": 30}, "left_shoulder": {"forward": 30}, "right_elbow": {"bend": 90, "twist": 45}, "left_elbow": {"bend": 90, "twist": -45}},
"translations": {"hips": {"y": 35}}
},
"pump_right_hard": {
"rotations": {"spine": {"tilt": -15, "turn": -20}, "right_shoulder": {"raise": 160}, "right_elbow": {"bend": 120}, "left_elbow": {"bend": 20}, "right_knee": {"bend": 10}, "left_knee": {"bend": 30}},
"translations": {"hips": {"x": 15, "y": -5}}
},
"pump_left_hard": {
"rotations": {"spine": {"tilt": 15, "turn": 20}, "left_shoulder": {"raise": 160}, "left_elbow": {"bend": 120}, "right_elbow": {"bend": 20}, "left_knee": {"bend": 10}, "right_knee": {"bend": 30}},
"translations": {"hips": {"x": -15, "y": -5}}
},
"arms_wide": {
"rotations": {"spine": {"forward": -10}, "right_shoulder": {"raise": 90, "forward": -20}, "left_shoulder": {"raise": 90, "forward": -20}, "right_elbow": {"bend": 10}, "left_elbow": {"bend": 10}}
},
"step_right": {
"rotations": {"spine": {"tilt": -10, "turn": -15}, "right_knee": {"bend": 40}, "left_knee": {"bend": 10}, "right_elbow": {"bend": 60}, "left_elbow": {"bend": 20}},
"translations": {"hips": {"x": 20, "y": 5}, "r_ankle": {"x": 20, "z": 10}}
},
"step_left": {
"rotations": {"spine": {"tilt": 10, "turn": 15}, "left_knee": {"bend": 40}, "right_knee": {"bend": 10}, "left_elbow": {"bend": 60}, "right_elbow": {"bend": 20}},
"translations": {"hips": {"x": -20, "y": 5}, "l_ankle": {"x": -20, "z": 10}}
},
"slide_left": {
"description": "Slide step left",
"rotations": { "left_knee": {"bend": 30}, "right_knee": {"bend": 10} },
"translations": { "hips": {"x": -20, "y": 5}, "l_ankle": {"x": -20, "z": 10} }
},
"slide_right": {
"description": "Slide step right",
"rotations": { "right_knee": {"bend": 30}, "left_knee": {"bend": 10} },
"translations": { "hips": {"x": 20, "y": 5}, "r_ankle": {"x": 20, "z": 10} }
},
"lunge_right": { "rotations": { "right_knee": {"bend": 70}, "left_knee": {"bend": 20}, "right_ankle": {"bend": 30}, "spine": {"forward": 10} }, "translations": { "hips": {"x": 15, "y": 15, "z": 15}, "r_ankle": {"x": 15, "z": 15} } },
"lunge_left": { "rotations": { "left_knee": {"bend": 70}, "right_knee": {"bend": 20}, "left_ankle": {"bend": 30}, "spine": {"forward": 10} }, "translations": { "hips": {"x": -15, "y": 15, "z": -15}, "l_ankle": {"x": -15, "z": 15} } },
"running_man": { "rotations": { "right_knee": {"bend": 70}, "left_knee": {"bend": 20}, "right_shoulder": {"raise": 45, "forward": 40}, "left_shoulder": {"raise": -20, "forward": -20} }, "translations": { "hips": {"y": 10}, "r_ankle": {"y": 10, "z": -10} } },
# EXPANDED LIBRARY (IMPORTED & PRESERVED)
"arms_up": { "rotations": { "right_shoulder": {"raise": 120}, "left_shoulder": {"raise": 120}, "right_elbow": {"bend": -30}, "left_elbow": {"bend": -30} } },
"arms_out": { "rotations": { "right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90} } },
"arms_crossed_low": { "rotations": { "right_shoulder": {"raise": 30, "forward": 40}, "left_shoulder": {"raise": 30, "forward": 40}, "right_elbow": {"bend": 80}, "left_elbow": {"bend": 80} } },
"dab": { "rotations": { "right_shoulder": {"raise": 120, "forward": 20}, "left_shoulder": {"raise": 45, "forward": 60}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 90}, "head": {"turn": -45, "nod": 30} } },
"floss_right": { "rotations": { "right_shoulder": {"raise": 45, "forward": -60}, "left_shoulder": {"raise": 45, "forward": 60}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}, "spine": {"twist": 30} }, "translations": {"hips": {"x": -8}} },
"floss_left": { "rotations": { "right_shoulder": {"raise": 45, "forward": 60}, "left_shoulder": {"raise": 45, "forward": -60}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}, "spine": {"twist": -30} }, "translations": {"hips": {"x": 8}} },
"disco_point_up": { "rotations": { "right_shoulder": {"raise": 140, "forward": 20}, "right_elbow": {"bend": 0}, "left_shoulder": {"raise": -10}, "spine": {"tilt": -10, "forward": -5} } },
"disco_point_down": { "rotations": { "right_shoulder": {"raise": 45, "forward": 45}, "right_elbow": {"bend": 0}, "left_shoulder": {"raise": 100}, "left_elbow": {"bend": -20} } },
"finger_guns": { "rotations": { "right_shoulder": {"raise": 90, "forward": 30}, "left_shoulder": {"raise": 90, "forward": 30}, "right_elbow": {"bend": 45}, "left_elbow": {"bend": 45} } },
"breakdance_freeze": { "rotations": { "right_shoulder": {"raise": 120, "forward": 70}, "left_shoulder": {"raise": 120, "forward": 70}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}, "spine": {"forward": 50} }, "translations": { "hips": {"y": 25} } },
"pop_lock": { "rotations": { "right_shoulder": {"raise": 90, "forward": 60}, "left_shoulder": {"raise": 90, "forward": -60}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}, "spine": {"twist": 30} } },
"headbang_forward": { "rotations": { "head": {"nod": 60}, "spine": {"forward": 30}, "right_shoulder": {"raise": -10}, "left_shoulder": {"raise": -10} } },
"headbang_back": { "rotations": { "head": {"nod": -40}, "spine": {"forward": -10} } },
"rock_out": { "rotations": { "right_shoulder": {"raise": 120}, "left_shoulder": {"raise": 120}, "right_elbow": {"bend": 60}, "left_elbow": {"bend": 60} } },
"air_guitar": { "rotations": { "right_shoulder": {"raise": 45, "forward": 50}, "left_shoulder": {"raise": 70, "forward": 40}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 60}, "spine": {"twist": 15} } },
"gangnam_style": { "rotations": { "right_shoulder": {"raise": 40, "forward": 45}, "left_shoulder": {"raise": 40, "forward": 45}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}, "right_knee": {"bend": 50}, "left_knee": {"bend": 50}, "spine": {"forward": 15} }, "translations": { "hips": {"y": 15} } },
"chicken_dance": { "rotations": { "right_shoulder": {"raise": 80}, "left_shoulder": {"raise": 80}, "right_elbow": {"bend": 120}, "left_elbow": {"bend": 120} } },
# ... (Includes all 80+ poses from adopter's list, abbreviated for space but fully supported via preset list) ...
"windmill_right": {"rotations": {"right_shoulder": {"raise": 180, "forward": 45}, "right_elbow": {"bend": 0}}},
"windmill_left": {"rotations": {"left_shoulder": {"raise": 180, "forward": 45}, "left_elbow": {"bend": 0}}},
"trex_arms": {"rotations": {"right_shoulder": {"raise": 45, "forward": 60}, "left_shoulder": {"raise": 45, "forward": 60}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}}},
"chicken_wings": {"rotations": {"right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90}, "right_elbow": {"bend": 110}, "left_elbow": {"bend": 110}}},
"robot_arms": {"rotations": {"right_shoulder": {"raise": 90, "forward": 0}, "left_shoulder": {"raise": 90, "forward": 0}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}}},
"airplane": {"rotations": {"right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}}},
"arms_crossed_high": {"rotations": {"right_shoulder": {"raise": 90, "forward": 80}, "left_shoulder": {"raise": 90, "forward": 80}, "right_elbow": {"bend": 100}, "left_elbow": {"bend": 100}}},
"superman": {"rotations": {"right_shoulder": {"raise": 120, "forward": 45}, "left_shoulder": {"raise": 120, "forward": 45}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}, "spine": {"forward": 15}}},
"one_arm_wave": {"rotations": {"right_shoulder": {"raise": 150}, "left_shoulder": {"raise": -10}, "right_elbow": {"bend": -20}, "spine": {"tilt": 10}}},
"twisted_reach": {"rotations": {"right_shoulder": {"raise": 140, "forward": -30}, "left_shoulder": {"raise": 20, "forward": 40}, "spine": {"twist": 45, "tilt": 15}}},
"diagonal_stretch": {"rotations": {"right_shoulder": {"raise": 160}, "left_shoulder": {"raise": -20}, "right_knee": {"bend": 15}, "left_knee": {"bend": 45}, "spine": {"tilt": 30}}, "translations": {"hips": {"x": -10, "y": 8}}},
"pretzel_twist": {"rotations": {"right_shoulder": {"raise": 45, "forward": 80}, "left_shoulder": {"raise": 110, "forward": -40}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 30}, "spine": {"twist": 60, "tilt": -20}}},
"side_bend_reach": {"rotations": {"right_shoulder": {"raise": 170}, "left_shoulder": {"raise": -15}, "spine": {"tilt": 45}}, "translations": {"upper": {"x": 15}}},
"moonwalk_lean": {"rotations": {"right_knee": {"bend": 20}, "left_knee": {"bend": 20}, "right_ankle": {"bend": -15}, "left_ankle": {"bend": -15}, "spine": {"forward": 35}}},
"thriller": {"rotations": {"right_shoulder": {"raise": 90, "forward": 60}, "left_shoulder": {"raise": 90, "forward": 60}, "right_elbow": {"bend": 60}, "left_elbow": {"bend": 60}, "head": {"nod": -20}}},
"ymca_y": {"rotations": {"right_shoulder": {"raise": 130}, "left_shoulder": {"raise": 130}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}}},
"ymca_m": {"rotations": {"right_shoulder": {"raise": 120}, "left_shoulder": {"raise": 120}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}}},
"deep_squat": {"rotations": {"right_knee": {"bend": 90}, "left_knee": {"bend": 90}, "right_ankle": {"bend": 30}, "left_ankle": {"bend": 30}, "spine": {"forward": 30}}, "translations": {"all": {"y": 30}}},
"backbend": {"rotations": {"spine": {"forward": -45}, "right_shoulder": {"raise": 150, "forward": -30}, "left_shoulder": {"raise": 150, "forward": -30}}, "translations": {"upper": {"y": -10}}},
"forward_fold": {"rotations": {"spine": {"forward": 90}, "right_shoulder": {"raise": 20, "forward": 40}, "left_shoulder": {"raise": 20, "forward": 40}}, "translations": {"upper": {"y": 20}}},
"jump_prep": {"rotations": {"right_knee": {"bend": 50}, "left_knee": {"bend": 50}, "right_ankle": {"bend": 25}, "left_ankle": {"bend": 25}, "spine": {"forward": 20}, "right_shoulder": {"raise": -20, "forward": 30}, "left_shoulder": {"raise": -20, "forward": 30}}, "translations": {"all": {"y": 20}}},
"jump_peak": {"rotations": {"right_knee": {"bend": -10}, "left_knee": {"bend": -10}, "right_shoulder": {"raise": 120}, "left_shoulder": {"raise": 120}, "spine": {"forward": -10}}, "translations": {"all": {"y": -25}}},
"side_tilt_extreme": {"rotations": {"spine": {"tilt": 50}, "right_shoulder": {"raise": 160}, "left_shoulder": {"raise": -10}}, "translations": {"upper": {"x": 18}}},
"twist_extreme": {"rotations": {"spine": {"twist": 70}, "right_shoulder": {"raise": 90, "forward": -45}, "left_shoulder": {"raise": 90, "forward": 45}, "head": {"turn": 80}}},
"arabesque": {"rotations": {"right_knee": {"bend": -20}, "left_knee": {"bend": 0}, "spine": {"forward": 25}, "right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90}}, "translations": {"upper": {"y": 5}}},
"warrior_pose": {"rotations": {"right_knee": {"bend": 60}, "left_knee": {"bend": 0}, "right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90}, "spine": {"forward": 5}}, "translations": {"hips": {"x": 10, "z": 15}}},
"tree_pose": {"rotations": {"left_knee": {"bend": 90}, "right_knee": {"bend": 0}, "right_shoulder": {"raise": 150}, "left_shoulder": {"raise": 150}}},
"star_jump": {"rotations": {"right_shoulder": {"raise": 110}, "left_shoulder": {"raise": 110}, "right_elbow": {"bend": 0}, "left_elbow": {"bend": 0}, "right_knee": {"bend": -30}, "left_knee": {"bend": -30}}},
"splits_prep": {"rotations": {"right_knee": {"bend": 0}, "left_knee": {"bend": 0}, "spine": {"forward": 40}}, "translations": {"all": {"y": 25}}},
"bridge_prep": {"rotations": {"spine": {"forward": -50}, "right_shoulder": {"raise": 170, "forward": -40}, "left_shoulder": {"raise": 170, "forward": -40}, "right_knee": {"bend": 70}, "left_knee": {"bend": 70}}, "translations": {"all": {"y": 20}}},
"celebrate": {"rotations": {"right_shoulder": {"raise": 160}, "left_shoulder": {"raise": 160}, "right_elbow": {"bend": 70}, "left_elbow": {"bend": 70}, "spine": {"forward": -15}}, "translations": {"upper": {"y": -5}}},
"defeated": {"rotations": {"spine": {"forward": 45}, "right_shoulder": {"raise": -10, "forward": 30}, "left_shoulder": {"raise": -10, "forward": 30}, "head": {"nod": 40}}, "translations": {"all": {"y": 10}}},
"thinking": {"rotations": {"right_shoulder": {"raise": 90, "forward": 60}, "right_elbow": {"bend": 90}, "head": {"nod": 20, "tilt": 10}}},
"shrug": {"rotations": {"right_shoulder": {"raise": 30}, "left_shoulder": {"raise": 30}, "right_elbow": {"bend": 45}, "left_elbow": {"bend": 45}, "head": {"tilt": 15}}, "translations": {"upper": {"y": -3}}},
"pointing_accusatory": {"rotations": {"right_shoulder": {"raise": 90, "forward": 45}, "right_elbow": {"bend": 0}, "left_shoulder": {"raise": -5}, "spine": {"twist": 20}}},
"dramatic_turn": {"rotations": {"spine": {"twist": 80, "tilt": 15}, "right_shoulder": {"raise": 60, "forward": -30}, "left_shoulder": {"raise": 40, "forward": 60}, "head": {"turn": 90}}},
"confident_stance": {"rotations": {"right_shoulder": {"raise": 30, "forward": 50}, "left_shoulder": {"raise": 30, "forward": 50}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}, "spine": {"forward": -5}}},
"wave_prep": {"rotations": {"spine": {"forward": 40}, "right_shoulder": {"raise": 70}, "left_shoulder": {"raise": 70}, "right_knee": {"bend": 30}, "left_knee": {"bend": 30}}, "translations": {"hips": {"y": 12}}},
"tutting": {"rotations": {"right_shoulder": {"raise": 90}, "left_shoulder": {"raise": 90}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}}},
"swagger_lean": {"rotations": {"spine": {"forward": -20, "tilt": 10}, "right_shoulder": {"raise": -10}, "left_shoulder": {"raise": -10}}},
"mosh_stance": {"rotations": {"right_knee": {"bend": 35}, "left_knee": {"bend": 35}, "right_shoulder": {"raise": 60, "forward": 50}, "left_shoulder": {"raise": 60, "forward": 50}, "spine": {"forward": 25}}, "translations": {"all": {"y": 10}}},
"scarecrow": {"rotations": {"right_shoulder": {"raise": 100}, "left_shoulder": {"raise": 100}, "right_elbow": {"bend": 150}, "left_elbow": {"bend": 150}, "head": {"tilt": 30}, "spine": {"tilt": 10}}},
"puppet_strings": {"rotations": {"right_shoulder": {"raise": 140}, "left_shoulder": {"raise": 140}, "right_elbow": {"bend": 90}, "left_elbow": {"bend": 90}, "right_knee": {"bend": 40}, "left_knee": {"bend": 40}}},
"mannequin": {"rotations": {"right_shoulder": {"raise": 70, "forward": 20}, "left_shoulder": {"raise": 50}, "right_elbow": {"bend": 60}, "spine": {"twist": 20, "tilt": -5}}},
"zombie_walk": {"rotations": {"right_shoulder": {"raise": 80, "forward": 70}, "left_shoulder": {"raise": 80, "forward": 70}, "right_elbow": {"bend": 150}, "left_elbow": {"bend": 150}, "right_knee": {"bend": 25}, "left_knee": {"bend": 10}, "spine": {"forward": 20}}},
"slav_squat": {"rotations": {"right_knee": {"bend": 100}, "left_knee": {"bend": 100}, "right_ankle": {"bend": 45}, "left_ankle": {"bend": 45}, "spine": {"forward": 35}}, "translations": {"all": {"y": 35}}},
"matrix_dodge": {"rotations": {"spine": {"forward": -40, "tilt": 25}, "right_knee": {"bend": 30}, "left_knee": {"bend": 50}, "right_shoulder": {"raise": 120}, "left_shoulder": {"raise": 100}}},
}
MOVE_COMBOS = {
"low": [
["step_left", "step_right", "step_left", "step_right"],
["lean_left", "lean_right", "lean_left", "lean_right"],
["step_left", "lean_left", "step_right", "lean_right"],
],
"medium": [
["pump_left_hard", "pump_right_hard", "pump_left_hard", "pump_right_hard"],
["arms_wide", "step_left", "arms_wide", "step_right"],
["step_left", "arms_up_open", "step_right", "arms_up_open"],
["lean_left", "pump_left_hard", "lean_right", "pump_right_hard"],
],
"high": [
["pump_right_hard", "pump_right_hard", "pump_left_hard", "pump_left_hard"],
["arms_up_open", "arms_wide", "power_crouch", "arms_wide"],
["lean_left", "pump_right_hard", "lean_right", "pump_left_hard"],
]
}
# PRESET STYLE LISTS
PRESET_SEQUENCES = {
"hip_hop": ["dab", "running_man", "pop_lock", "breakdance_freeze", "gangnam_style"],
"pop_dance": ["dab", "floss_right", "floss_left", "disco_point_up", "finger_guns", "arms_up_open"],
"rock": ["headbang_forward", "headbang_back", "rock_out", "air_guitar", "arms_wide"],
"disco": ["disco_point_up", "disco_point_down", "finger_guns", "arms_wide", "step_right", "step_left"],
"fitness": ["lunge_right", "lunge_left", "power_crouch", "arms_up_open", "step_left", "step_right"],
"edm_rave": ["windmill_right", "windmill_left", "star_jump", "jump_prep", "jump_peak", "arms_up", "celebrate"],
"thriller_horror": ["zombie_walk", "thriller", "scarecrow", "puppet_strings", "mannequin", "defeated"],
"meme_dances": ["dab", "floss_right", "floss_left", "gangnam_style", "chicken_dance", "slav_squat"],
"internet_viral": ["dab", "floss_right", "gangnam_style", "moonwalk_lean", "matrix_dodge", "slav_squat"],
"classic_disco": ["disco_point_up", "disco_point_down", "finger_guns", "ymca_y", "ymca_m", "arms_out"],
"breakdance": ["breakdance_freeze", "windmill_right", "windmill_left", "running_man", "pop_lock", "wave_prep"],
"yoga_flow": ["warrior_pose", "tree_pose", "arabesque", "confident_stance", "neutral"],
"crazy_chaos": ["zombie_walk", "slav_squat", "matrix_dodge", "trex_arms", "chicken_dance", "puppet_strings", "scarecrow"],
"gymnastics": ["arabesque", "warrior_pose", "tree_pose", "star_jump", "splits_prep", "bridge_prep", "backbend"],
"expressive": ["celebrate", "defeated", "thinking", "shrug", "dramatic_turn", "confident_stance", "pointing_accusatory"],
"arms_only": ["arms_up", "arms_out", "pump_right_hard", "pump_left_hard", "windmill_right", "windmill_left", "airplane"],
"body_moves": ["lean_right", "lean_left", "twist_extreme", "side_tilt_extreme"],
"extreme_energy": ["jump_peak", "star_jump", "windmill_right", "backbend", "twist_extreme", "matrix_dodge"],
"chill_vibe": ["neutral", "swagger_lean", "confident_stance", "thinking", "lean_right", "lean_left"],
"michael_jackson": ["moonwalk_lean", "thriller", "dab", "celebrate", "finger_guns"],
"fortnite": ["dab", "floss_right", "floss_left", "robot_arms", "celebrate"],
}
# ============================================================================
# MOTION DYNAMICS (PHYSICS & CONSTRAINTS)
# ============================================================================
class MotionDynamics:
# 0 = head, 1 = neck
JOINT_DRAG = {
0: 0.1, 1: 0.1, 2: 0.05, 5: 0.05,
3: 0.08, 6: 0.08, 4: 0.15, 7: 0.15,
8: 0.05, 11: 0.05,
9: 0.05, 12: 0.05,
10: 0.01, 13: 0.01
}
def __init__(self, start_pose, num_joints=18):
self.num_joints = num_joints
self.velocities = np.zeros((num_joints, 3))
self.positions = start_pose.copy()
self.dt = 1.0 / 24.0
# --- FIXED BONE LENGTH CALCULATION FOR MULTIPLE CHARACTERS ---
# Calculate bone lengths for every character individually based on their specific start pose
self.bone_lengths = {}
for j in range(num_joints):
base_idx = j % 18
parent_base = JOINT_PARENTS.get(base_idx, -1)
if parent_base != -1:
# Find the parent index within the same character chunk
char_offset = (j // 18) * 18
parent_idx = parent_base + char_offset
# Store distance
self.bone_lengths[j] = np.linalg.norm(start_pose[j] - start_pose[parent_idx])
def step_towards(self, target_pose, urgency=1.0):
# Physics update
for j in range(self.num_joints):
# Map index to 0-17 range for property lookup
base_idx = j % 18
drag = self.JOINT_DRAG.get(base_idx, 0.1)
stiffness = 20.0 * urgency
# Sticky feet
if base_idx in [10, 13]:
stiffness = 60.0 * urgency
drag = 0.0
displacement = target_pose[j] - self.positions[j]
spring_force = displacement * stiffness
damping = 1.5 * np.sqrt(stiffness)
damping_force = -self.velocities[j] * damping
acceleration = spring_force + damping_force
self.velocities[j] += acceleration * self.dt
self.velocities[j] *= (1.0 - drag)
self.positions[j] += self.velocities[j] * self.dt
# --- BONE LENGTH CONSTRAINT SOLVER ---
solve_order = [0, 2, 5, 8, 11, 3, 4, 6, 7, 9, 10, 12, 13, 14, 15, 16, 17]
# Iterate over every character chunk
for char_idx in range(self.num_joints // 18):
offset = char_idx * 18
for child_base in solve_order:
child = child_base + offset
parent_base = JOINT_PARENTS[child_base]
if parent_base == -1: continue
parent = parent_base + offset
current_vec = self.positions[child] - self.positions[parent]
current_dist = np.linalg.norm(current_vec)
# LOOKUP CORRECT LENGTH FOR THIS SPECIFIC JOINT
target_dist = self.bone_lengths.get(child, current_dist)
if current_dist > 1e-4:
correction = current_vec * (target_dist / current_dist)
self.positions[child] = self.positions[parent] + correction
return self.positions.copy()
# ============================================================================
# BEAT DRIVEN POSE NODE (THE BRAIN)
# ============================================================================
class SCAILBeatDrivenPose:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_pose": ("SCAIL_POSE", {"tooltip": "Base skeleton pose from DWPose or Generator"}),
"beat_info": ("SCAIL_BEAT_INFO", {"tooltip": "Beat detection data from SCAILBeatDetector"}),
"audio_features": ("SCAIL_AUDIO_FEATURES", {"tooltip": "Audio analysis from SCAILAudioFeatureExtractor"}),
"dance_style": (["auto"] + sorted(list(PRESET_SEQUENCES.keys())), {"default": "auto", "tooltip": "Dance style preset. 'auto' selects moves based on energy level."}),
"interaction_mode": (["unison", "mirror", "random"], {"default": "mirror", "tooltip": "Multi-character coordination: unison=same moves, mirror=left/right swapped, random=independent"}),
"energy_style": (["auto", "low", "medium", "high"], {"default": "auto", "tooltip": "Override energy detection. 'auto' uses audio RMS."}),
"motion_smoothness": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 3.0, "tooltip": "Lower = snappier, higher = smoother/floaty"}),
"anticipation": ("INT", {"default": 3, "min": 0, "max": 10, "tooltip": "Frames to start moving BEFORE beat"}),
"groove_amount": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 3.0, "tooltip": "Hip sway/bounce intensity synced to tempo"}),
"bass_intensity": ("FLOAT", {"default": 1.2, "min": 0.0, "max": 5.0, "tooltip": "How much bass drives motion snap"}),
"treble_intensity": ("FLOAT", {"default": 1.2, "min": 0.0, "max": 5.0, "tooltip": "How much treble drives arm movement"}),
"phrase_bars": ([4, 8, 16], {"default": 8, "tooltip": "Bars per phrase. Choreography changes on phrase boundaries."}),
"staging_mode": (["off", "subtle", "dynamic"], {"default": "subtle", "tooltip": "Stage movement: off=stationary, subtle=small sway, dynamic=larger travel"}),
"hit_easing": (["linear", "ease_out", "ease_in_out", "bounce", "elastic", "punch"], {"default": "ease_out", "tooltip": "Motion curve for pose transitions. Affects how moves hit the beat."}),
"pose_blend": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "tooltip": "Blend between poses. 0=hard keyframes, 1=maximum smoothing between poses"}),
"loop_mode": ("BOOLEAN", {"default": False, "tooltip": "Ensure first and last frames match for seamless looping"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 99999, "tooltip": "Random seed for deterministic choreography"}),
}
}
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
RETURN_NAMES = ("pose_sequence",)
FUNCTION = "generate"
CATEGORY = "SCAIL-AudioReactive"
# Joint Groups for Rotation
GROUPS = {
"spine": [1, 2, 5, 8, 11], "head": [0, 14, 15, 16, 17],
"right_shoulder": [2, 3, 4], "left_shoulder": [5, 6, 7],
"right_elbow": [3, 4], "left_elbow": [6, 7],
"right_knee": [9, 10], "left_knee": [12, 13],
"right_ankle": [10], "left_ankle": [13]
}
PIVOTS = {
"spine": 1, "head": 1, "right_shoulder": 2, "left_shoulder": 5,
"right_elbow": 3, "left_elbow": 6, "right_knee": 9, "left_knee": 12,
"right_ankle": 10, "left_ankle": 13
}
AXES = {"nod": 0, "forward": 0, "bend": 0, "turn": 1, "twist": 1, "tilt": 2, "raise": 2}
def _create_neutral_structure(self, char_pose):
"""Construct a neutral standing pose based on the limb lengths of the reference.
Straightens both legs AND arms to create a consistent base for dance poses."""
neutral = char_pose.copy()
# === NEUTRALIZE LEGS ===
r_hip = char_pose[8]
l_hip = char_pose[11]
r_leg_vec = char_pose[10] - r_hip
l_leg_vec = char_pose[13] - l_hip
r_len = np.linalg.norm(r_leg_vec)
l_len = np.linalg.norm(l_leg_vec)
# Preserve 20% of stance width/depth to prevent legs clipping
r_stance_x = r_leg_vec[0] * 0.2
r_stance_z = r_leg_vec[2] * 0.2
l_stance_x = l_leg_vec[0] * 0.2
l_stance_z = l_leg_vec[2] * 0.2
r_y = np.sqrt(max(0, r_len**2 - r_stance_x**2 - r_stance_z**2))
neutral[10] = r_hip + np.array([r_stance_x, r_y, r_stance_z])
r_dir = neutral[10] - r_hip
r_dir_norm = r_dir / (np.linalg.norm(r_dir) + 1e-8)
r_thigh_len = np.linalg.norm(char_pose[9] - char_pose[8])
neutral[9] = r_hip + r_dir_norm * r_thigh_len
l_y = np.sqrt(max(0, l_len**2 - l_stance_x**2 - l_stance_z**2))
neutral[13] = l_hip + np.array([l_stance_x, l_y, l_stance_z])
l_dir = neutral[13] - l_hip
l_dir_norm = l_dir / (np.linalg.norm(l_dir) + 1e-8)
l_thigh_len = np.linalg.norm(char_pose[12] - char_pose[11])
neutral[12] = l_hip + l_dir_norm * l_thigh_len
# === NEUTRALIZE ARMS ===
# Right arm: shoulder(2) -> elbow(3) -> wrist(4)
r_shoulder = char_pose[2]
r_upper_arm_len = np.linalg.norm(char_pose[3] - char_pose[2])
r_forearm_len = np.linalg.norm(char_pose[4] - char_pose[3])
# Neutral arm position: slightly down and forward (relaxed idle)
r_arm_dir = np.array([-0.8, 0.5, 0.2]) # Down, slightly out, slightly forward
r_arm_dir = r_arm_dir / (np.linalg.norm(r_arm_dir) + 1e-8)
neutral[3] = r_shoulder + r_arm_dir * r_upper_arm_len # elbow
neutral[4] = neutral[3] + r_arm_dir * r_forearm_len # wrist
# Left arm: shoulder(5) -> elbow(6) -> wrist(7)
l_shoulder = char_pose[5]
l_upper_arm_len = np.linalg.norm(char_pose[6] - char_pose[5])
l_forearm_len = np.linalg.norm(char_pose[7] - char_pose[6])
# Mirror for left arm
l_arm_dir = np.array([0.8, 0.5, 0.2]) # Down, slightly out (mirrored), slightly forward
l_arm_dir = l_arm_dir / (np.linalg.norm(l_arm_dir) + 1e-8)
neutral[6] = l_shoulder + l_arm_dir * l_upper_arm_len # elbow
neutral[7] = neutral[6] + l_arm_dir * l_forearm_len # wrist
return neutral
def _get_body_scale(self, char_pose):
neck = char_pose[1]
mid_hip = (char_pose[8] + char_pose[11]) / 2.0
torso_len = np.linalg.norm(neck - mid_hip)
STANDARD_TORSO = 160.0
if torso_len < 1.0: return 1.0
return torso_len / STANDARD_TORSO
def _apply_pose(self, base, pose_name, scale_factor=1.0):
pose_def = DANCE_POSES.get(pose_name, DANCE_POSES["neutral"])
res = base.copy()
for group, motions in pose_def.get("rotations", {}).items():
for motion, deg in motions.items():
if group in self.GROUPS and motion in self.AXES:
axis = self.AXES[motion]
rad = np.radians(deg)
if "left" in group and motion in ["raise", "tilt"]: rad = -rad
res = apply_chain_rotation(res, self.PIVOTS[group], self.GROUPS[group], axis, rad)
for target, off in pose_def.get("translations", {}).items():
vec = np.array([off.get("x",0), off.get("y",0), off.get("z",0)]) * scale_factor
if target == "hips":
for i in [1, 2, 5, 8, 11]:
res[i] += vec
elif target == "all":
res += vec
elif target in JOINT_NAMES:
idx = JOINT_NAMES.index(target)
res[idx] += vec
return res
def _apply_continuous_groove(self, joints, time, tempo, amount, scale_factor=1.0):
beat_period = 60.0 / (tempo + 1e-5)
phase = (time / beat_period) * 2 * np.pi
sway = np.sin(phase * 0.5) * 20.0 * amount * scale_factor
bounce = np.abs(np.cos(phase)) * 8.0 * amount * scale_factor
indices = [1, 2, 5, 8, 11] # Neck, Shoulders, Hips
joints[indices, 0] += sway
joints[indices, 1] += bounce
joints = apply_chain_rotation(joints, 1, [0, 14, 15, 16, 17], 2, np.radians(-sway * 0.1))
return joints
def generate(self, base_pose, beat_info, audio_features, dance_style, interaction_mode, energy_style, motion_smoothness, anticipation, groove_amount, bass_intensity, treble_intensity, phrase_bars, staging_mode, hit_easing, pose_blend, loop_mode, seed):
rng = np.random.default_rng(seed)
base_joints_all = base_pose["joints"].numpy()
char_count = base_pose.get("char_count", 1)
print(f"[SCAIL] Animating {char_count} characters. Style: {dance_style}, Phrase: {phrase_bars} bars, Easing: {hit_easing}, Loop: {loop_mode}")
# Get easing function
easing_func = EASING_FUNCTIONS.get(hit_easing, ease_out)
frame_count = beat_info["frame_count"]
fps = beat_info["fps"]
beat_frames = beat_info["beat_frames"]
downbeat_frames = beat_info.get("downbeat_frames", beat_frames[::4] if len(beat_frames) >= 4 else beat_frames)
tempo = beat_info["tempo"]
energy = beat_info["energy"].numpy()
bass = audio_features["bass"].numpy()
treble = audio_features["treble"].numpy()
if len(bass) != frame_count:
bass = np.interp(np.linspace(0,1,frame_count), np.linspace(0,1,len(bass)), bass)
treble = np.interp(np.linspace(0,1,frame_count), np.linspace(0,1,len(treble)), treble)
# Calculate phrase timing
if len(beat_frames) >= 2:
frames_per_beat = float(np.median(np.diff(beat_frames)))
else:
frames_per_beat = (60.0 * fps) / max(tempo, 1.0)
phrase_len_frames = int(frames_per_beat * 4.0 * phrase_bars) # 4 beats per bar
phrase_len_frames = max(1, phrase_len_frames)
# Build phrase boundaries from downbeats or regular intervals
if len(downbeat_frames) >= 2:
phrase_starts = [int(downbeat_frames[i]) for i in range(0, len(downbeat_frames), phrase_bars)]
else:
phrase_starts = list(range(0, frame_count, phrase_len_frames))
if not phrase_starts or phrase_starts[0] != 0:
phrase_starts = [0] + phrase_starts
phrase_start_set = set(phrase_starts)
# Staging positions (normalized, will be scaled per character)
stage_positions = [
np.array([0.0, 0.0, 0.0]),
np.array([0.3, 0.0, 0.0]),
np.array([-0.3, 0.0, 0.0]),
np.array([0.15, 0.1, 0.0]),
np.array([-0.15, 0.1, 0.0]),
np.array([0.2, -0.08, 0.0]),
np.array([-0.2, -0.08, 0.0]),
]
# Scales: subtle = gentle sway, dynamic = noticeable travel
staging_scale = {"off": 0.0, "subtle": 0.4, "dynamic": 0.8}.get(staging_mode, 0.4)
char_keyframes = []
char_stage_targets = [] # Track stage position per character
for c in range(char_count):
keyframes = {0: "neutral"}
current_combo = []
combo_idx = 0
current_phrase = -1
char_rng = np.random.default_rng(seed + c)
# Initialize stage position
char_stage_targets.append({"current": np.zeros(3), "target": np.zeros(3), "start_frame": 0})
for beat_frame in beat_frames:
target_frame = max(0, beat_frame - anticipation)
# Check if we're in a new phrase
phrase_idx = sum(1 for ps in phrase_starts if ps <= target_frame) - 1
new_phrase = phrase_idx != current_phrase
if new_phrase:
current_phrase = phrase_idx
# Pick new combo for this phrase
phrase_start = phrase_starts[phrase_idx] if phrase_idx < len(phrase_starts) else 0
phrase_end = phrase_starts[phrase_idx + 1] if phrase_idx + 1 < len(phrase_starts) else frame_count
local_energy = np.mean(energy[phrase_start:phrase_end])
if dance_style == "auto":
style = energy_style if energy_style != "auto" else ("high" if local_energy > 0.6 else "medium" if local_energy > 0.3 else "low")
available_combos = MOVE_COMBOS.get(style, MOVE_COMBOS["medium"])
else:
available_combos = [PRESET_SEQUENCES.get(dance_style, PRESET_SEQUENCES["hip_hop"])]
if c == 0 or interaction_mode == "random":
current_combo = available_combos[char_rng.integers(0, len(available_combos))]
combo_idx = 0
# Update stage target for this phrase
if staging_mode != "off":
new_stage = stage_positions[char_rng.integers(0, len(stage_positions))]
char_stage_targets[c]["target"] = new_stage * staging_scale
char_stage_targets[c]["start_frame"] = target_frame
pose_name = "neutral"
if c == 0 or interaction_mode == "random":
pose_name = current_combo[combo_idx % len(current_combo)]
elif interaction_mode == "unison":
pose_name = char_keyframes[0].get(target_frame, "neutral")
# Copy leader's stage target
if new_phrase and staging_mode != "off":
char_stage_targets[c]["target"] = char_stage_targets[0]["target"].copy()
char_stage_targets[c]["start_frame"] = target_frame
elif interaction_mode == "mirror":
leader_pose = char_keyframes[0].get(target_frame, "neutral")
if "left" in leader_pose: pose_name = leader_pose.replace("left", "right")
elif "right" in leader_pose: pose_name = leader_pose.replace("right", "left")
else: pose_name = leader_pose
# Mirror leader's stage X position
if new_phrase and staging_mode != "off":
leader_target = char_stage_targets[0]["target"]
char_stage_targets[c]["target"] = np.array([-leader_target[0], leader_target[1], leader_target[2]])
char_stage_targets[c]["start_frame"] = target_frame
keyframes[target_frame] = pose_name
combo_idx += 1
char_keyframes.append(keyframes)
# Loop mode: ensure last pose returns to first pose
if loop_mode and len(beat_frames) > 0:
# Add a keyframe at the end that mirrors the start
loop_blend_frames = int(frames_per_beat * 2) # 2 beats to blend back
for c in range(char_count):
first_pose = char_keyframes[c].get(0, "neutral")
char_keyframes[c][max(0, frame_count - loop_blend_frames)] = first_pose
# Build sorted keyframe list per character for interpolation
char_keyframe_times = []
char_keyframe_poses = []
for c in range(char_count):
times = sorted(char_keyframes[c].keys())
poses = [char_keyframes[c][t] for t in times]
char_keyframe_times.append(times)
char_keyframe_poses.append(poses)
motion = MotionDynamics(base_joints_all.copy(), num_joints=18*char_count)
motion.dt = 1.0 / fps
pose_sequence = []
# Pre-compute all target poses for blending
char_pose_cache = {} # (char_idx, pose_name) -> pose array
def get_pose_for_char(c, pose_name):
key = (c, pose_name)
if key not in char_pose_cache:
char_ref = base_joints_all[c*18:(c+1)*18]
char_base = self._create_neutral_structure(char_ref)
scale = self._get_body_scale(char_ref)
char_pose_cache[key] = self._apply_pose(char_base, pose_name, scale_factor=scale)
return char_pose_cache[key]
current_targets = [base_joints_all[i*18:(i+1)*18].copy() for i in range(char_count)]
for c in range(char_count):
char_ref = base_joints_all[c*18:(c+1)*18]
char_neutral = self._create_neutral_structure(char_ref)
current_targets[c] = char_neutral
for i in range(frame_count):
full_target_pose = []
for c in range(char_count):
char_ref = base_joints_all[c*18:(c+1)*18]
scale = self._get_body_scale(char_ref)
times = char_keyframe_times[c]
poses = char_keyframe_poses[c]
# Find surrounding keyframes for blending
prev_idx = 0
for idx, t in enumerate(times):
if t <= i:
prev_idx = idx
else:
break
next_idx = min(prev_idx + 1, len(times) - 1)
prev_time = times[prev_idx]
next_time = times[next_idx]
prev_pose_name = poses[prev_idx]
next_pose_name = poses[next_idx]
prev_pose = get_pose_for_char(c, prev_pose_name)
next_pose = get_pose_for_char(c, next_pose_name)
# Calculate blend factor with easing
if next_time > prev_time:
raw_t = (i - prev_time) / (next_time - prev_time)
raw_t = np.clip(raw_t, 0.0, 1.0)
# Apply easing function
eased_t = easing_func(raw_t)
# Apply pose_blend parameter (0 = snap to keyframes, 1 = full blend)
blend_t = eased_t * pose_blend + (1.0 if raw_t >= 1.0 else 0.0) * (1.0 - pose_blend)
else:
blend_t = 1.0
# Blend between poses
blended_pose = prev_pose * (1.0 - blend_t) + next_pose * blend_t
current_targets[c] = blended_pose
# Apply staging offset with smooth interpolation
if staging_mode != "off":
st = char_stage_targets[c]
# Smooth interpolation toward target
blend = 0.02 # Gradual movement
st["current"] = st["current"] * (1 - blend) + st["target"] * blend
# Clamp to safe range (don't let characters walk out of frame)
max_offset = 0.5 # Maximum normalized offset
st["current"][0] = np.clip(st["current"][0], -max_offset, max_offset)
st["current"][1] = np.clip(st["current"][1], -max_offset * 0.4, max_offset * 0.4)
stage_offset = st["current"] * scale * 120 # Scale to world units
current_targets[c] = current_targets[c] + stage_offset
full_target_pose.append(current_targets[c])
flat_target = np.concatenate(full_target_pose, axis=0)
urgency = 1.0 / motion_smoothness
if bass[i] > 0.7: urgency *= 1.5
joints = motion.step_towards(flat_target, urgency)
for c in range(char_count):
start = c * 18
end = (c + 1) * 18
char_joints = joints[start:end]
char_ref = base_joints_all[start:end]
scale = self._get_body_scale(char_ref)
time_sec = i / fps
char_joints = self._apply_continuous_groove(char_joints, time_sec, tempo, groove_amount, scale_factor=scale)
bounce = bass[i] * bass_intensity * 10.0 * scale
char_joints[[1,2,5,8,11], 1] += bounce
if treble[i] > 0.1:
t_val = treble[i] * treble_intensity * 15.0
char_joints = apply_chain_rotation(char_joints, 3, [3,4], 0, np.radians(t_val))
char_joints = apply_chain_rotation(char_joints, 6, [6,7], 0, np.radians(t_val))
hip_mid_x = (char_joints[8,0] + char_joints[11,0]) / 2.0
tilt = (char_joints[1,0] - hip_mid_x) * 0.1
char_joints = apply_chain_rotation(char_joints, 1, [0,14,15,16,17], 2, np.radians(-tilt * 0.5))
joints[start:end] = char_joints
pose_sequence.append(joints.copy())
# Loop mode: blend last few frames back to first frame
if loop_mode and len(pose_sequence) > 1:
blend_frames = min(int(frames_per_beat), len(pose_sequence) // 4)
if blend_frames > 0:
first_pose = pose_sequence[0]
for b in range(blend_frames):
idx = len(pose_sequence) - blend_frames + b
t = (b + 1) / (blend_frames + 1)
t = easing_func(t) # Apply easing to loop blend too
pose_sequence[idx] = pose_sequence[idx] * (1.0 - t) + first_pose * t
return ({"poses": pose_sequence, "limb_seq": base_pose["limb_seq"], "bone_colors": base_pose["bone_colors"]},)
# ============================================================================
# RENDERER
# ============================================================================
class SCAILPoseRenderer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pose_sequence": ("SCAIL_POSE_SEQUENCE", {"tooltip": "Animated pose sequence from SCAILBeatDrivenPose"}),
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "tooltip": "Output image width in pixels"}),
"height": ("INT", {"default": 768, "min": 64, "max": 4096, "tooltip": "Output image height in pixels"}),
"auto_half_resolution": ("BOOLEAN", {"default": True, "tooltip": "Render at half res then upscale. Faster."}),
"fov": ("FLOAT", {"default": 55.0, "min": 10.0, "max": 120.0, "tooltip": "Field of view in degrees"}),
"cylinder_pixel_radius": ("FLOAT", {"default": 4.0, "min": 1.0, "max": 20.0, "tooltip": "Thickness of skeleton limbs"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("pose_images",)
FUNCTION = "render"
CATEGORY = "SCAIL-AudioReactive"
def render(self, pose_sequence, width, height, auto_half_resolution, fov, cylinder_pixel_radius):
import taichi as ti
rw, rh = (width//2, height//2) if auto_half_resolution else (width, height)
scale = 0.5 if auto_half_resolution else 1.0
try: ti.init(arch=ti.gpu, default_fp=ti.f32)
except: pass
poses, limb_seq, colors = pose_sequence["poses"], pose_sequence["limb_seq"], pose_sequence["bone_colors"]
fov_rad = np.radians(fov)
focal = max(rh, rw) / (np.tan(fov_rad/2) * 2)
avg_z = np.mean([p[1,2] for p in poses]) if poses else 800.0
cyl_radius = cylinder_pixel_radius * avg_z / focal
specs_list = []
for joints in poses:
frame_specs = []
for i, (s, e) in enumerate(limb_seq):
if s >= len(joints) or e >= len(joints): continue # Safety check
sp = joints[s].copy() * np.array([scale, scale, 1])
ep = joints[e].copy() * np.array([scale, scale, 1])
if np.sum(np.abs(sp)) > 1e-3:
frame_specs.append((sp.tolist(), ep.tolist(), colors[i % len(colors)]))
specs_list.append(frame_specs)
frames_rgba = render_whole(specs_list, H=rh, W=rw, fx=focal, fy=focal, cx=rw/2, cy=rh/2, radius=cyl_radius)
out = np.stack(frames_rgba, axis=0).astype(np.float32) / 255.0
return (torch.from_numpy(out[:,:,:,:3]).float(),)
# ============================================================================
# UTILITIES
# ============================================================================
class SCAILPoseFromDWPose:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_width": ("INT", {"default": 512, "min": 64, "max": 4096, "tooltip": "Width of source image that DWPose was run on"}),
"image_height": ("INT", {"default": 768, "min": 64, "max": 4096, "tooltip": "Height of source image that DWPose was run on"}),
"depth": ("FLOAT", {"default": 800.0, "min": 100.0, "max": 5000.0, "tooltip": "Assumed Z-depth for 2D to 3D conversion"}),
"fov": ("FLOAT", {"default": 55.0, "min": 10.0, "max": 120.0, "tooltip": "Field of view for projection. Match your render FOV."}),
},
"optional": {
"dw_poses": ("DWPOSES", {"tooltip": "DWPose output (preferred format)"}),
"pose_keypoint": ("POSE_KEYPOINT", {"tooltip": "Alternative OpenPose-style keypoint input"})
}
}
RETURN_TYPES = ("SCAIL_POSE",)
FUNCTION = "extract"
CATEGORY = "SCAIL-AudioReactive"
def extract(self, image_width, image_height, depth, fov, dw_poses=None, pose_keypoint=None):
data = dw_poses if dw_poses else pose_keypoint
# NOTE: This only extracts ONE character as base. Multi-char is handled by Generator.
if not data: return SCAILBasePoseGenerator().generate("active_idle", 1, 150.0, 400.0, depth, 0, 0)
candidates = []
# Handle various DWPose formats
if isinstance(data, list) and len(data) > 0:
# Check frame 0
frame_data = data[0]
if 'bodies' in frame_data and 'candidate' in frame_data['bodies']:
candidates = frame_data['bodies']['candidate']
elif 'candidate' in frame_data:
candidates = frame_data['candidate']
elif isinstance(data, torch.Tensor):
# If tensor, assume single person or handled upstream?
candidates = data.cpu().numpy()
if candidates.ndim == 2: candidates = [candidates]
elif isinstance(data, dict):
# Handle Single Frame Dictionary Input (Common from OpenPose)
if 'bodies' in data and 'candidate' in data['bodies']:
candidates = data['bodies']['candidate']
elif 'candidate' in data:
candidates = data['candidate']
if len(candidates) == 0:
print("[SCAIL] No candidates found in DWPose data. Fallback to Generator.")
return SCAILBasePoseGenerator().generate("active_idle", 1, 150.0, 400.0, depth, 0, 0)
print(f"[SCAIL] Found {len(candidates)} candidates in DWPose data.")
# Processing candidates
total_joints = []
full_limb_seq = []
full_colors = []
focal = max(image_width, image_height) / (np.tan(np.radians(fov)/2)*2)
cx, cy = image_width/2, image_height/2
char_count = 0
for i, kps in enumerate(candidates):
kps = np.array(kps)
if kps.shape != (18, 2) and kps.shape != (18, 3):
continue # Skip invalid shapes
joints = np.zeros((18, 3), dtype=np.float32)
valid_joints = 0
for j in range(18):
x, y = kps[j,0], kps[j,1]
# Normalize check
if x <= 1.5 and x > 0: x *= image_width
if y <= 1.5 and y > 0: y *= image_height
if x > 0 and y > 0:
joints[j] = [(x-cx)*depth/focal, (y-cy)*depth/focal, depth]
valid_joints += 1
# Interpolate missing
for j in range(18):
if np.all(joints[j]==0) and JOINT_PARENTS[j]!=-1:
# Simple heuristic: duplicate parent
joints[j] = joints[JOINT_PARENTS[j]] + [0,10,0]
if valid_joints > 0:
total_joints.append(joints)
# Add limbs and colors for this character
index_offset = char_count * 18
for (s, e) in LIMB_SEQ:
full_limb_seq.append((s + index_offset, e + index_offset))
full_colors.extend(BONE_COLORS)
char_count += 1
if char_count == 0:
return SCAILBasePoseGenerator().generate("active_idle", 1, 150.0, 400.0, depth, 0, 0)
final_joints = np.concatenate(total_joints, axis=0)
return ({"joints": torch.from_numpy(final_joints).float(),
"limb_seq": full_limb_seq,
"bone_colors": full_colors,
"char_count": char_count,
"joint_names": JOINT_NAMES},)
class SCAILAlignPoseToReference:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pose_sequence": ("SCAIL_POSE_SEQUENCE", {"tooltip": "Animated pose sequence to align"}),
"reference_pose": ("SCAIL_POSE", {"tooltip": "Target pose to align to (uses neck position)"})
}
}
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
RETURN_NAMES = ("aligned_sequence",)
FUNCTION = "align"
CATEGORY = "SCAIL-AudioReactive"
def align(self, pose_sequence, reference_pose):
poses = [p.copy() for p in pose_sequence["poses"]]
if not poses: return (pose_sequence,)
# Align NECK of First Character
offset = reference_pose["joints"][1].numpy() - poses[0][1]
for i in range(len(poses)): poses[i] += offset
return ({"poses": poses, "limb_seq": pose_sequence["limb_seq"], "bone_colors": pose_sequence["bone_colors"]},)
# ============================================================================
# AIST++ CHUNK LIBRARY NODES
# ============================================================================
# HuggingFace dataset URL
AIST_HF_URL = "https://huggingface.co/datasets/ckinpdx/aist_chunks/resolve/main/aist_chunks_v2.tar.gz"
def get_aist_library_path():
"""Get default path for AIST chunks - in ComfyUI models folder"""
# Try to find ComfyUI folder
comfy_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(comfy_path, "models", "aist_chunks")
def download_aist_library(destination):
"""Download and extract AIST chunks from HuggingFace"""
os.makedirs(destination, exist_ok=True)
tar_path = os.path.join(destination, "aist_chunks.tar.gz")
print(f"[AIST] Downloading from HuggingFace...")
print(f"[AIST] This is ~100MB, may take a few minutes...")
# Download with progress
def report_progress(block_num, block_size, total_size):
downloaded = block_num * block_size
percent = min(100, downloaded * 100 // total_size)
mb_down = downloaded / (1024 * 1024)
mb_total = total_size / (1024 * 1024)
print(f"\r[AIST] Downloading: {percent}% ({mb_down:.1f}/{mb_total:.1f} MB)", end="", flush=True)
urllib.request.urlretrieve(AIST_HF_URL, tar_path, report_progress)
print() # newline after progress
print(f"[AIST] Extracting...")
with tarfile.open(tar_path, "r:gz") as tar:
tar.extractall(destination)
# Move contents up if nested (tar might create aist_chunks/aist_chunks/)
nested = os.path.join(destination, "aist_chunks")
if os.path.exists(nested) and os.path.isdir(nested):
for item in os.listdir(nested):
src = os.path.join(nested, item)
dst = os.path.join(destination, item)
if not os.path.exists(dst):
shutil.move(src, dst)
shutil.rmtree(nested)
# Clean up tar
os.remove(tar_path)
print(f"[AIST] Done! Library at: {destination}")
class SCAILAISTLibraryLoader:
"""
Loads the AIST++ chunk library. Auto-downloads from HuggingFace on first use.
"""
@classmethod
def INPUT_TYPES(cls):
default_path = get_aist_library_path()
return {
"required": {
"library_path": ("STRING", {
"default": default_path,
"tooltip": "Path to aist_chunks folder. Leave default to auto-download."
}),
"auto_download": ("BOOLEAN", {
"default": True,
"tooltip": "Automatically download from HuggingFace if not found"
}),
}
}
RETURN_TYPES = ("AIST_LIBRARY",)
RETURN_NAMES = ("library",)
FUNCTION = "load"
CATEGORY = "SCAIL-AudioReactive/AIST"
def load(self, library_path, auto_download):
index_path = os.path.join(library_path, "index.json")
# Check if we need to download
if not os.path.exists(index_path):
if auto_download:
print(f"[AIST] Library not found at {library_path}")
download_aist_library(library_path)
else:
raise FileNotFoundError(
f"index.json not found in {library_path}. "
"Enable auto_download or provide correct path."
)
# Load index
with open(index_path, 'r') as f:
index = json.load(f)
index['_base_path'] = library_path
total = sum(
len(chunks)
for genre in index['chunks'].values()
for chunks in genre.values()
)
print(f"[AIST] Loaded library: {len(index['chunks'])} genres, {total} chunks")
return (index,)
class SCAILAISTBeatDance:
"""
Generates dance sequences from AIST++ chunks, triggered by beats.
Selects chunks based on audio energy and blends from reference pose.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"library": ("AIST_LIBRARY", {"tooltip": "AIST chunk library from loader"}),
"beat_info": ("SCAIL_BEAT_INFO", {"tooltip": "Beat detection from SCAILBeatDetector"}),
"audio_features": ("SCAIL_AUDIO_FEATURES", {"tooltip": "Audio features for energy mapping"}),
"sync_dancers": ("BOOLEAN", {"default": True, "tooltip": "All dancers do same moves (True) or independent (False)"}),
"genre_break": ("BOOLEAN", {"default": True, "tooltip": "Include breakdance moves"}),
"genre_pop": ("BOOLEAN", {"default": True, "tooltip": "Include popping moves"}),
"genre_lock": ("BOOLEAN", {"default": True, "tooltip": "Include locking moves"}),
"genre_waack": ("BOOLEAN", {"default": False, "tooltip": "Include waacking moves"}),
"genre_krump": ("BOOLEAN", {"default": False, "tooltip": "Include krumping moves"}),
"genre_house": ("BOOLEAN", {"default": False, "tooltip": "Include house moves"}),
"genre_street_jazz": ("BOOLEAN", {"default": False, "tooltip": "Include street jazz moves"}),
"genre_ballet_jazz": ("BOOLEAN", {"default": False, "tooltip": "Include ballet jazz moves"}),
"genre_la_hip_hop": ("BOOLEAN", {"default": False, "tooltip": "Include LA hip hop moves"}),
"genre_middle_hip_hop": ("BOOLEAN", {"default": False, "tooltip": "Include middle hip hop moves"}),
"transition_frames": ("INT", {
"default": 6, "min": 1, "max": 30,
"tooltip": "Frames to blend between chunks"
}),
"chunks_per_beat": ("INT", {
"default": 1, "min": 1, "max": 4,
"tooltip": "Chain consecutive chunks together (1=0.5s, 2=1s, 3=1.5s, 4=2s per beat)"
}),
"energy_sensitivity": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 3.0,
"tooltip": "How strongly audio energy affects chunk selection"
}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffff,
"tooltip": "Random seed for chunk selection"
}),
},
"optional": {
"reference_pose": ("SCAIL_POSE", {
"tooltip": "Starting pose from DWPose to blend from"
}),
}
}
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
RETURN_NAMES = ("pose_sequence",)
FUNCTION = "generate"
CATEGORY = "SCAIL-AudioReactive/AIST"
def generate(self, library, beat_info, audio_features, sync_dancers,
genre_break, genre_pop, genre_lock, genre_waack, genre_krump,
genre_house, genre_street_jazz, genre_ballet_jazz,
genre_la_hip_hop, genre_middle_hip_hop,
transition_frames, chunks_per_beat, energy_sensitivity, seed, reference_pose=None):
random.seed(seed)
np.random.seed(seed)
base_path = library['_base_path']
source_fps = library['fps'] # 60
target_fps = audio_features['fps'] # likely 24
frame_count = audio_features['frame_count']
# Build genre list from boolean flags
genre_map = {
'break': genre_break, 'pop': genre_pop, 'lock': genre_lock,
'waack': genre_waack, 'krump': genre_krump, 'house': genre_house,
'street_jazz': genre_street_jazz, 'ballet_jazz': genre_ballet_jazz,
'la_hip_hop': genre_la_hip_hop, 'middle_hip_hop': genre_middle_hip_hop
}
genre_list = [g for g, enabled in genre_map.items() if enabled and g in library['chunks']]
if not genre_list:
genre_list = list(library['chunks'].keys())[:1] # fallback to first genre
# Determine number of characters from reference pose
char_count = 1
ref_data = [] # Store scale, floor_y, and center_x for each character
# We need a sample AIST full height to calculate scale
# AIST full body Y-span is ~136 units (from lowest foot to head)
AIST_AVG_HEIGHT = 136.0
def calc_leg_angle(hip, knee, ankle):
"""Calculate angle at knee - 180° = straight leg"""
v1 = hip - knee
v2 = ankle - knee
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8)
return np.degrees(np.arccos(np.clip(cos_angle, -1, 1)))
if reference_pose is not None:
ref_joints = reference_pose['joints'].numpy()
char_count = reference_pose.get('char_count', 1)
# Build ref_data with per-character scale based on full height
for i in range(char_count):
start_idx = i * 18
end_idx = start_idx + 18
if end_idx <= len(ref_joints):
char_joints = ref_joints[start_idx:end_idx]
# Helper to check if joint is valid (not zero/origin)
def is_valid(joint_idx):
j = char_joints[joint_idx]
return abs(j[0]) > 1e-3 or abs(j[1]) > 1e-3 or abs(j[2]) > 1e-3
# Cascading scale detection for partial poses
full_height = None
char_scale = None
scale_method = "default"
ref_height = 200
l_angle = 0
r_angle = 0
# 1. Try full height with leg angle detection (best) - requires head + full legs
if is_valid(0) and is_valid(8) and is_valid(9) and is_valid(10) and is_valid(11) and is_valid(12) and is_valid(13):
# Get leg joints
l_hip = char_joints[11]
l_knee = char_joints[12]
l_ankle = char_joints[13]
r_hip = char_joints[8]
r_knee = char_joints[9]
r_ankle = char_joints[10]
# Calculate leg angles
l_angle = calc_leg_angle(l_hip, l_knee, l_ankle)
r_angle = calc_leg_angle(r_hip, r_knee, r_ankle)
# Only use straight legs (>160°), average them
straight_legs = []
if l_angle > 160:
straight_legs.append(l_ankle[1])
if r_angle > 160:
straight_legs.append(r_ankle[1])
if straight_legs:
foot_y = sum(straight_legs) / len(straight_legs)
else:
foot_y = max(l_ankle[1], r_ankle[1])
head_y = char_joints[0][1]
full_height = abs(foot_y - head_y)
if full_height > 10:
char_scale = full_height / AIST_AVG_HEIGHT
scale_method = "full_height_leg_angle"
# Also compute ref_height (neck to ankle)
if is_valid(1):
neck = char_joints[1]
ankle_y = (l_ankle[1] + r_ankle[1]) / 2
ref_height = abs(ankle_y - neck[1])
# 2. Try full height without leg angle (head + at least one ankle)
if char_scale is None and is_valid(0) and (is_valid(10) or is_valid(13)):
head_y = char_joints[0][1]
l_ankle_y = char_joints[13][1] if is_valid(13) else char_joints[10][1]
r_ankle_y = char_joints[10][1] if is_valid(10) else char_joints[13][1]
foot_y = max(l_ankle_y, r_ankle_y)
full_height = abs(foot_y - head_y)
if full_height > 10:
char_scale = full_height / AIST_AVG_HEIGHT
scale_method = "full_height_simple"
ref_height = full_height * 0.85
# 3. Try torso (neck to hips) - good for upper body shots
if char_scale is None and is_valid(1) and (is_valid(8) or is_valid(11)):
neck = char_joints[1]
r_hip = char_joints[8] if is_valid(8) else char_joints[11]
l_hip = char_joints[11] if is_valid(11) else char_joints[8]
mid_hip = (r_hip + l_hip) / 2
torso_len = np.linalg.norm(neck - mid_hip)
if torso_len > 5:
full_height = torso_len * 2.5
char_scale = full_height / AIST_AVG_HEIGHT
scale_method = "torso"
ref_height = torso_len * 2.1
# 4. Try shoulder width - decent for close-ups
if char_scale is None and is_valid(2) and is_valid(5):
r_shoulder = char_joints[2]
l_shoulder = char_joints[5]
shoulder_width = np.linalg.norm(l_shoulder - r_shoulder)
if shoulder_width > 5:
full_height = shoulder_width * 4
char_scale = full_height / AIST_AVG_HEIGHT
scale_method = "shoulders"
ref_height = shoulder_width * 3.4
# 5. Fallback to defaults
if char_scale is None:
full_height = 200
char_scale = 1.0
scale_method = "default"
ref_height = 200
print(f"[AIST] Char {i}: method={scale_method} l_angle={l_angle:.0f}° r_angle={r_angle:.0f}° height={full_height:.1f} scale={char_scale:.2f}")
# Get position/depth from best available joint
if is_valid(1):
neck = char_joints[1]
center_x = neck[0]
depth_z = neck[2]
elif is_valid(2) and is_valid(5):
center_x = (char_joints[2][0] + char_joints[5][0]) / 2
depth_z = (char_joints[2][2] + char_joints[5][2]) / 2
else:
center_x = 256
depth_z = 800
# Floor Y from lowest valid joint
valid_y = [char_joints[j][1] for j in range(18) if is_valid(j)]
floor_y = max(valid_y) if valid_y else 400
ref_data.append({
'height': ref_height,
'floor_y': floor_y,
'center_x': center_x,
'depth_z': depth_z,
'scale': char_scale
})
else:
ref_data.append({
'height': 200,
'floor_y': 400,
'center_x': 256,
'depth_z': 800,
'scale': 1.0
})
else:
# Default if no reference
ref_data.append({
'height': 200,
'floor_y': 400,
'center_x': 256,
'depth_z': 800,
'scale': 1.0
})
print(f"[AIST] Using genres: {genre_list}")
# Get beat frames
beat_frames = beat_info.get('beat_frames', [])
if len(beat_frames) == 0:
# No beats - distribute evenly
beat_frames = list(range(0, frame_count, 30))
# Get energy per frame (use bass as primary driver)
bass = audio_features['bass'].numpy()
# Build chunk pools by energy level
chunk_pools = {'low': [], 'mid': [], 'high': []}
for genre in genre_list:
for level in ['low', 'mid', 'high']:
for chunk_info in library['chunks'][genre].get(level, []):
chunk_pools[level].append(chunk_info)
print(f"[AIST] Chunk pools - L:{len(chunk_pools['low'])} M:{len(chunk_pools['mid'])} H:{len(chunk_pools['high'])}")
# Build lookup for consecutive chunks (same source, next start frame)
# Filename format: {source}_f{start:04d}.npy
chunk_sequences = {} # source -> sorted list of chunk_infos by start frame
for genre in genre_list:
for level in ['low', 'mid', 'high']:
for chunk_info in library['chunks'][genre].get(level, []):
# Parse filename to get source and start frame
fname = os.path.basename(chunk_info['file'])
# e.g., "d07_mJB0_ch01_f0336.npy"
parts = fname.replace('.npy', '').rsplit('_f', 1)
if len(parts) == 2:
source = parts[0]
try:
start_frame = int(parts[1])
if source not in chunk_sequences:
chunk_sequences[source] = []
chunk_sequences[source].append((start_frame, chunk_info))
except ValueError:
pass
# Sort each source's chunks by start frame
for source in chunk_sequences:
chunk_sequences[source].sort(key=lambda x: x[0])
def find_next_chunk(chunk_info):
"""Find the next consecutive chunk from same source."""
fname = os.path.basename(chunk_info['file'])
parts = fname.replace('.npy', '').rsplit('_f', 1)
if len(parts) != 2:
return None
source = parts[0]
try:
current_start = int(parts[1])
except ValueError:
return None
if source not in chunk_sequences:
return None
# Find next chunk (start frame should be current + step, where step=24)
for start_frame, info in chunk_sequences[source]:
if start_frame > current_start:
return info
return None
# Shared state for synced dancers, or per-character state for independent
def make_state():
return {
'current_chunk': None,
'chunk_data': None,
'chunk_frame': 0,
'chunk_frames': 30, # Will be set per-chunk from index
'prev_chunk_data': None,
'prev_last_pose': None,
'transition_frame': 0,
'in_transition': False,
'chunks_remaining': 0, # How many more chunks in current chain
}
if sync_dancers:
shared_state = make_state()
else:
char_states = [make_state() for _ in range(char_count)]
# Generate output poses
output_poses = []
# Time scaling: 60fps chunks -> target_fps output
time_scale = source_fps / target_fps # e.g., 60/24 = 2.5
def get_pose_from_state(state, frame_idx, is_beat, level, pool, base_path, time_scale, transition_frames, chunks_per_beat):
"""Get interpolated pose from state, handling chunk transitions."""
# Calculate max output frames for current chunk
chunk_frames = state['chunk_frames']
max_output_frames = int(chunk_frames / time_scale)
# Check if we need to switch chunks (on beat, or no chunk, or finished chain)
need_new_chunk = (
state['chunk_data'] is None or
(is_beat and state['chunks_remaining'] <= 0)
)
# Check if current chunk ended and we should chain to next
if state['chunk_data'] is not None and state['chunk_frame'] >= max_output_frames:
if state['chunks_remaining'] > 0:
# Chain to next consecutive chunk
next_chunk = find_next_chunk(state['current_chunk'])
if next_chunk is not None:
# Smooth transition - save last pose
state['prev_last_pose'] = state['chunk_data'][-1].copy()
state['in_transition'] = True
state['transition_frame'] = 0
state['current_chunk'] = next_chunk
chunk_path = os.path.join(base_path, state['current_chunk']['file'])
state['chunk_data'] = np.load(chunk_path)
state['chunk_frames'] = next_chunk.get('frames', len(state['chunk_data']))
state['chunk_frame'] = 0
state['chunks_remaining'] -= 1
else:
# No consecutive chunk found - pick new random one with transition
if pool:
state['prev_last_pose'] = state['chunk_data'][-1].copy()
state['in_transition'] = True
state['transition_frame'] = 0
new_chunk = random.choice(pool)
state['current_chunk'] = new_chunk
chunk_path = os.path.join(base_path, new_chunk['file'])
state['chunk_data'] = np.load(chunk_path)
state['chunk_frames'] = new_chunk.get('frames', len(state['chunk_data']))
state['chunk_frame'] = 0
state['chunks_remaining'] -= 1
else:
state['chunk_frame'] = max_output_frames - 1
state['chunks_remaining'] = 0
else:
# Hold last frame until next beat
state['chunk_frame'] = max_output_frames - 1
if need_new_chunk and pool:
# Save previous pose for blending
if state['chunk_data'] is not None:
src_frame_idx = min(state['chunk_frame'], len(state['chunk_data']) - 1)
state['prev_last_pose'] = state['chunk_data'][src_frame_idx].copy()
state['in_transition'] = True
state['transition_frame'] = 0
# Load new chunk
new_chunk = random.choice(pool)
state['current_chunk'] = new_chunk
chunk_path = os.path.join(base_path, new_chunk['file'])
state['chunk_data'] = np.load(chunk_path)
state['chunk_frames'] = new_chunk.get('frames', len(state['chunk_data']))
state['chunk_frame'] = 0
state['chunks_remaining'] = chunks_per_beat - 1
# Calculate adaptive transition frames based on max joint distance
if state['prev_last_pose'] is not None and len(state['chunk_data']) > 0:
new_first_pose = state['chunk_data'][0]
max_dist = 0
for j in range(min(len(state['prev_last_pose']), len(new_first_pose))):
dist = np.linalg.norm(state['prev_last_pose'][j] - new_first_pose[j])
max_dist = max(max_dist, dist)
# Scale: ~0.3 frames per unit distance, clamped to [4, transition_frames]
state['adaptive_transition'] = int(np.clip(max_dist * 0.3, 4, transition_frames))
else:
state['adaptive_transition'] = transition_frames
if state['chunk_data'] is None:
return np.zeros((18, 3), dtype=np.float32)
# Sample from current chunk (using this chunk's frame count)
chunk_frames = state['chunk_frames']
src_frame = state['chunk_frame'] * time_scale
src_frame_low = int(src_frame)
src_frame_high = min(src_frame_low + 1, chunk_frames - 1)
t = src_frame - src_frame_low
if src_frame_low < chunk_frames:
pose = (1 - t) * state['chunk_data'][src_frame_low] + t * state['chunk_data'][src_frame_high]
else:
pose = state['chunk_data'][-1].copy()
# Blend from previous chunk if in transition
if state['in_transition'] and state['prev_last_pose'] is not None:
adaptive_frames = state.get('adaptive_transition', transition_frames)
blend_t = state['transition_frame'] / adaptive_frames
# Smooth easing
blend_t = blend_t * blend_t * (3 - 2 * blend_t)
pose = (1 - blend_t) * state['prev_last_pose'] + blend_t * pose
state['transition_frame'] += 1
if state['transition_frame'] >= adaptive_frames:
state['in_transition'] = False
state['prev_last_pose'] = None
return pose
for frame_idx in range(frame_count):
# Check if we hit a beat
is_beat = frame_idx in beat_frames
# Get energy level for this frame
energy = bass[min(frame_idx, len(bass)-1)] * energy_sensitivity
if energy < 0.33:
level = 'low'
elif energy < 0.66:
level = 'mid'
else:
level = 'high'
pool = chunk_pools[level]
# Build frame with all characters
all_joints = []
for char_idx in range(char_count):
if sync_dancers:
state = shared_state
else:
state = char_states[char_idx]
# Get reference data for this character
ref = ref_data[char_idx] if char_idx < len(ref_data) else ref_data[0]
pose = get_pose_from_state(
state, frame_idx, is_beat, level, pool,
base_path, time_scale, transition_frames, chunks_per_beat
)
pose = pose.copy()
# Use fixed scale from reference pose
scale = ref['scale']
# Get body center for positioning
l_shoulder = pose[5]
r_shoulder = pose[2]
l_hip = pose[11]
r_hip = pose[8]
shoulder_center = (l_shoulder + r_shoulder) / 2
hip_center = (l_hip + r_hip) / 2
# Center and scale the pose (scale is fixed from reference)
pose_center_x = (shoulder_center[0] + hip_center[0]) / 2
pose_center_y = (shoulder_center[1] + hip_center[1]) / 2
# Scale around center
pose[:, 0] = (pose[:, 0] - pose_center_x) * scale
pose[:, 1] = (pose[:, 1] - pose_center_y) * scale
pose[:, 2] = ref['depth_z']
# Flip Y axis (AIST Y-up to renderer Y-down)
pose[:, 1] = -pose[:, 1]
# Position: center X, anchor lowest point to floor
pose[:, 0] = pose[:, 0] + ref['center_x']
# Find lowest point (highest Y value after flip)
pose_floor_y = np.max(pose[:, 1])
floor_offset = ref['floor_y'] - pose_floor_y
pose[:, 1] = pose[:, 1] + floor_offset
all_joints.append(pose.astype(np.float32))
# Advance frame counter (once for sync, per-char for independent)
# Don't advance during transition - hold on first frame until blend complete
if sync_dancers:
if not shared_state['in_transition']:
shared_state['chunk_frame'] += 1
else:
for state in char_states:
if not state['in_transition']:
state['chunk_frame'] += 1
# Combine all characters into single array
frame_joints = np.concatenate(all_joints, axis=0)
output_poses.append(frame_joints)
# Blend from reference pose over transition frames (for all characters)
if reference_pose is not None and len(output_poses) > 0:
ref_joints = reference_pose['joints'].numpy()
blend_frames = min(transition_frames, len(output_poses))
for i in range(blend_frames):
t = i / blend_frames
# Smooth easing
t = t * t * (3 - 2 * t)
output_poses[i] = (1 - t) * ref_joints + t * output_poses[i]
# Build limb_seq and colors for all characters
full_limb_seq = []
full_colors = []
for i in range(char_count):
offset = i * 18
for (s, e) in LIMB_SEQ:
full_limb_seq.append((s + offset, e + offset))
full_colors.extend(BONE_COLORS)
return ({
"poses": output_poses,
"limb_seq": full_limb_seq,
"bone_colors": full_colors
},)
class SCAILAISTChunkPreview:
"""
Preview random chunks from the library for testing.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"library": ("AIST_LIBRARY", {"tooltip": "AIST chunk library"}),
"genre": ("STRING", {"default": "break", "tooltip": "Genre to preview"}),
"energy": (["low", "mid", "high"], {"default": "mid"}),
"chunk_index": ("INT", {"default": 0, "min": 0, "max": 10000}),
}
}
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
RETURN_NAMES = ("pose_sequence",)
FUNCTION = "preview"
CATEGORY = "SCAIL-AudioReactive/AIST"
def preview(self, library, genre, energy, chunk_index):
base_path = library['_base_path']
if genre not in library['chunks']:
genre = list(library['chunks'].keys())[0]
chunks = library['chunks'][genre].get(energy, [])
if not chunks:
raise ValueError(f"No {energy} chunks for {genre}")
chunk_info = chunks[chunk_index % len(chunks)]
chunk_path = os.path.join(base_path, chunk_info['file'])
chunk_data = np.load(chunk_path)
print(f"[AIST] Preview: {chunk_info['file']} (energy: {chunk_info['energy']:.2f})")
# Convert to pose list
poses = [chunk_data[i].astype(np.float32) for i in range(len(chunk_data))]
return ({
"poses": poses,
"limb_seq": LIMB_SEQ,
"bone_colors": BONE_COLORS
},)
# ============================================================================
# FULL SEQUENCE NODES
# ============================================================================
AIST_FULL_HF_URL = "https://huggingface.co/datasets/ckinpdx/aist_chunks/resolve/main/aist_npy.tar.gz"
def get_aist_full_path():
"""Get default path for full AIST sequences"""
comfy_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(comfy_path, "models", "aist_full")
def download_aist_full(destination, max_retries=3):
"""Download and extract full AIST sequences from HuggingFace"""
os.makedirs(destination, exist_ok=True)
tar_path = os.path.join(destination, "aist_npy.tar.gz")
for attempt in range(max_retries):
print(f"[AIST Full] Downloading from HuggingFace (attempt {attempt + 1}/{max_retries})...")
print(f"[AIST Full] This is ~200MB, may take a few minutes...")
try:
# Download with progress
def report_progress(block_num, block_size, total_size):
downloaded = block_num * block_size
percent = min(100, downloaded * 100 // total_size)
mb_down = downloaded / (1024 * 1024)
mb_total = total_size / (1024 * 1024)
print(f"\r[AIST Full] Downloading: {percent}% ({mb_down:.1f}/{mb_total:.1f} MB)", end="", flush=True)
urllib.request.urlretrieve(AIST_FULL_HF_URL, tar_path, report_progress)
print()
# Verify file size (should be ~200MB)
file_size = os.path.getsize(tar_path)
if file_size < 190 * 1024 * 1024: # Less than 190MB = incomplete
print(f"[AIST Full] Download incomplete ({file_size / 1024 / 1024:.1f}MB), retrying...")
os.remove(tar_path)
continue
print(f"[AIST Full] Download complete ({file_size / 1024 / 1024:.1f}MB)")
print(f"[AIST Full] Extracting...")
with tarfile.open(tar_path, "r:gz") as tar:
tar.extractall(destination)
os.remove(tar_path)
print(f"[AIST Full] Done! Library at: {destination}")
return True
except Exception as e:
print(f"\n[AIST Full] Error: {e}")
if os.path.exists(tar_path):
os.remove(tar_path)
if attempt < max_retries - 1:
print(f"[AIST Full] Retrying...")
else:
raise RuntimeError(
f"Failed to download AIST library after {max_retries} attempts. "
f"Please download manually from {AIST_FULL_HF_URL} and extract to {destination}"
)
# COCO 17 to OpenPose 18 conversion (full sequences are in COCO format)
def coco_to_openpose(coco):
"""Convert COCO 17 keypoints to OpenPose 18 format."""
single_frame = coco.ndim == 2
if single_frame:
coco = coco[np.newaxis, ...]
frames = coco.shape[0]
openpose = np.zeros((frames, 18, 3), dtype=np.float32)
mapping = {
0: 0, 1: 15, 2: 14, 3: 17, 4: 16, 5: 5, 6: 2,
7: 6, 8: 3, 9: 7, 10: 4, 11: 11, 12: 8,
13: 12, 14: 9, 15: 13, 16: 10,
}
for coco_idx, op_idx in mapping.items():
openpose[:, op_idx, :] = coco[:, coco_idx, :]
# Neck = midpoint of shoulders
openpose[:, 1, :] = (coco[:, 5, :] + coco[:, 6, :]) / 2
if single_frame:
return openpose[0]
return openpose
class SCAILAISTFullLoader:
"""
Loads full AIST++ sequences. Auto-downloads from HuggingFace on first use.
"""
@classmethod
def INPUT_TYPES(cls):
default_path = get_aist_full_path()
return {
"required": {
"library_path": ("STRING", {
"default": default_path,
"tooltip": "Path to aist_full folder. Leave default to auto-download."
}),
"auto_download": ("BOOLEAN", {
"default": True,
"tooltip": "Automatically download from HuggingFace if not found"
}),
}
}
RETURN_TYPES = ("AIST_FULL_LIBRARY",)
RETURN_NAMES = ("library",)
FUNCTION = "load"
CATEGORY = "SCAIL-AudioReactive/AIST"
def load(self, library_path, auto_download):
# Check if any genre folder exists
genres = ["break", "pop", "lock", "waack", "krump",
"street_jazz", "ballet_jazz", "la_hip_hop", "house", "middle_hip_hop"]
found_genres = [g for g in genres if os.path.exists(os.path.join(library_path, g))]
if not found_genres:
if auto_download:
print(f"[AIST Full] Library not found at {library_path}")
download_aist_full(library_path)
found_genres = [g for g in genres if os.path.exists(os.path.join(library_path, g))]
else:
raise FileNotFoundError(f"No genre folders found in {library_path}")
# Build index of sequences
sequences = {}
total = 0
for genre in found_genres:
genre_path = os.path.join(library_path, genre)
files = [f for f in os.listdir(genre_path) if f.endswith('.npy')]
sequences[genre] = files
total += len(files)
print(f"[AIST Full] Loaded: {len(found_genres)} genres, {total} sequences")
return ({
'_base_path': library_path,
'sequences': sequences,
'fps': 60
},)
class SCAILAISTFullSequence:
"""
Plays full AIST++ dance sequences, time-scaled to target fps.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"library": ("AIST_FULL_LIBRARY", {"tooltip": "Full sequence library from loader"}),
"audio_features": ("SCAIL_AUDIO_FEATURES", {"tooltip": "Audio features for frame count/fps"}),
"genre": (["break", "pop", "lock", "waack", "krump", "house",
"street_jazz", "ballet_jazz", "la_hip_hop", "middle_hip_hop"],
{"default": "break", "tooltip": "Dance genre"}),
"transition_frames": ("INT", {
"default": 12, "min": 1, "max": 60,
"tooltip": "Frames to blend from reference pose to dance"
}),
"start_beat": ("INT", {
"default": 0, "min": 0, "max": 100,
"tooltip": "Which beat in the source to start from (0=beginning)"
}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffff,
"tooltip": "Random seed for sequence selection"
}),
},
"optional": {
"reference_pose": ("SCAIL_POSE", {
"tooltip": "Starting pose from DWPose to match scale/position"
}),
"beat_info": ("SCAIL_BEAT_INFO", {
"tooltip": "Optional beat info to start on a beat-aligned frame"
}),
}
}
RETURN_TYPES = ("SCAIL_POSE_SEQUENCE",)
RETURN_NAMES = ("pose_sequence",)
FUNCTION = "generate"
CATEGORY = "SCAIL-AudioReactive/AIST"
def generate(self, library, audio_features, genre, transition_frames, start_beat, seed,
reference_pose=None, beat_info=None):
random.seed(seed)
np.random.seed(seed)
base_path = library['_base_path']
source_fps = library['fps'] # 60
target_fps = audio_features['fps']
frame_count = audio_features['frame_count']
# Check genre exists
if genre not in library['sequences'] or not library['sequences'][genre]:
# Fallback to first available genre
genre = list(library['sequences'].keys())[0]
print(f"[AIST Full] Genre not found, using {genre}")
# Pick a random sequence from genre
fname = random.choice(library['sequences'][genre])
seq_path = os.path.join(base_path, genre, fname)
# Load and convert to OpenPose format
coco_data = np.load(seq_path)
sequence = coco_to_openpose(coco_data)
print(f"[AIST Full] Playing: {genre}/{fname} ({len(sequence)} frames @ {source_fps}fps)")
# Determine start frame
start_frame = 0
if beat_info is not None and start_beat > 0:
# Find beat-aligned start frame in source
# Estimate source beats assuming similar tempo
beat_frames = beat_info.get('beat_frames', [])
if len(beat_frames) > 1:
avg_beat_interval = np.mean(np.diff(beat_frames))
# Scale to source fps
source_beat_interval = avg_beat_interval * (source_fps / target_fps)
start_frame = int(start_beat * source_beat_interval)
start_frame = min(start_frame, len(sequence) - 1)
# Get reference data
char_count = 1
ref_data = []
if reference_pose is not None:
ref_joints = reference_pose['joints'].numpy()
char_count = reference_pose.get('char_count', 1)
for i in range(char_count):
start_idx = i * 18
end_idx = start_idx + 18
if end_idx <= len(ref_joints):
char_joints = ref_joints[start_idx:end_idx]
# Helper to check if joint is valid (not zero/origin)
def is_valid(joint_idx):
j = char_joints[joint_idx]
return abs(j[0]) > 1e-3 or abs(j[1]) > 1e-3 or abs(j[2]) > 1e-3
# Cascading detection for partial poses
ref_height = None
scale_method = "default"
# 1. Try neck to ankles (best)
if is_valid(1) and (is_valid(10) or is_valid(13)):
neck = char_joints[1]
l_ankle_y = char_joints[13][1] if is_valid(13) else char_joints[10][1]
r_ankle_y = char_joints[10][1] if is_valid(10) else char_joints[13][1]
ankle_y = (l_ankle_y + r_ankle_y) / 2 if is_valid(10) and is_valid(13) else max(l_ankle_y, r_ankle_y)
ref_height = abs(ankle_y - neck[1])
if ref_height > 10:
scale_method = "neck_to_ankle"
else:
ref_height = None
# 2. Try torso (neck to hips)
if ref_height is None and is_valid(1) and (is_valid(8) or is_valid(11)):
neck = char_joints[1]
r_hip = char_joints[8] if is_valid(8) else char_joints[11]
l_hip = char_joints[11] if is_valid(11) else char_joints[8]
mid_hip = (r_hip + l_hip) / 2
torso_len = np.linalg.norm(neck - mid_hip)
if torso_len > 5:
ref_height = torso_len * 2.1 # Estimate neck-to-ankle from torso
scale_method = "torso"
# 3. Try shoulder width
if ref_height is None and is_valid(2) and is_valid(5):
r_shoulder = char_joints[2]
l_shoulder = char_joints[5]
shoulder_width = np.linalg.norm(l_shoulder - r_shoulder)
if shoulder_width > 5:
ref_height = shoulder_width * 3.4 # Estimate neck-to-ankle from shoulders
scale_method = "shoulders"
# 4. Fallback
if ref_height is None:
ref_height = 200
scale_method = "default"
print(f"[AIST Full] Char {i}: scale_method={scale_method}, ref_height={ref_height:.1f}")
# Get position/depth from best available joint
if is_valid(1):
center_x = char_joints[1][0]
depth_z = char_joints[1][2]
elif is_valid(2) and is_valid(5):
center_x = (char_joints[2][0] + char_joints[5][0]) / 2
depth_z = (char_joints[2][2] + char_joints[5][2]) / 2
else:
center_x = 256
depth_z = 800
# Floor Y from lowest valid joint
valid_y = [char_joints[j][1] for j in range(18) if is_valid(j)]
floor_y = max(valid_y) if valid_y else 400
ref_data.append({
'height': ref_height,
'floor_y': floor_y,
'center_x': center_x,
'depth_z': depth_z
})
else:
ref_data.append({'height': 200, 'floor_y': 400, 'center_x': 256, 'depth_z': 800})
else:
ref_data.append({'height': 200, 'floor_y': 400, 'center_x': 256, 'depth_z': 800})
# Calculate scale from first frame
first_frame = sequence[start_frame]
l_shoulder = first_frame[5]
r_shoulder = first_frame[2]
l_hip = first_frame[11]
r_hip = first_frame[8]
shoulder_center = (l_shoulder + r_shoulder) / 2
hip_center = (l_hip + r_hip) / 2
torso_length = np.linalg.norm(shoulder_center - hip_center)
ref = ref_data[0]
ref_torso = ref['height'] * 0.4
scale = ref_torso / torso_length if torso_length > 0 else 1.0
# Time scaling
time_scale = source_fps / target_fps
# Build list of available sequences for chaining (exclude current)
available_sequences = [f for f in library['sequences'][genre] if f != fname]
# Track current sequence state
current_sequence = sequence
current_fname = fname
current_src_frame = start_frame
prev_last_pose = None # For transition blending
in_transition = False
transition_frame = 0
# Generate output poses
output_poses = []
for frame_idx in range(frame_count):
# Map to source frame
src_frame_float = current_src_frame + (frame_idx * time_scale)
# Adjust for sequence-local position after chaining
if frame_idx > 0:
src_frame_float = current_src_frame
current_src_frame += time_scale
src_frame_low = int(src_frame_float)
src_frame_high = min(src_frame_low + 1, len(current_sequence) - 1)
t = src_frame_float - src_frame_low
# Check if we need a new sequence
if src_frame_low >= len(current_sequence) - 1:
if available_sequences:
# Save last pose for blending
prev_last_pose = current_sequence[-1].copy()
in_transition = True
transition_frame = 0
# Pick new sequence (exclude current)
new_fname = random.choice(available_sequences)
available_sequences = [f for f in available_sequences if f != new_fname]
if not available_sequences:
# Refill pool, exclude only current
available_sequences = [f for f in library['sequences'][genre] if f != new_fname]
# Load new sequence
new_path = os.path.join(base_path, genre, new_fname)
new_coco = np.load(new_path)
current_sequence = coco_to_openpose(new_coco)
current_fname = new_fname
current_src_frame = 0
print(f"[AIST Full] Chaining to: {genre}/{new_fname} ({len(current_sequence)} frames)")
# Reset frame indices
src_frame_low = 0
src_frame_high = min(1, len(current_sequence) - 1)
t = 0
else:
# No more sequences, hold last frame
src_frame_low = len(current_sequence) - 1
src_frame_high = src_frame_low
t = 0
# Interpolate within current sequence
pose = (1 - t) * current_sequence[src_frame_low] + t * current_sequence[src_frame_high]
# Handle transition blending between sequences
if in_transition and prev_last_pose is not None:
blend_t = transition_frame / transition_frames
blend_t = blend_t * blend_t * (3 - 2 * blend_t) # Smooth easing
pose = (1 - blend_t) * prev_last_pose + blend_t * pose
transition_frame += 1
if transition_frame >= transition_frames:
in_transition = False
prev_last_pose = None
# Build output for all characters
all_joints = []
for char_idx in range(char_count):
char_pose = pose.copy()
char_ref = ref_data[char_idx] if char_idx < len(ref_data) else ref_data[0]
# Get body center
l_shoulder = char_pose[5]
r_shoulder = char_pose[2]
l_hip = char_pose[11]
r_hip = char_pose[8]
shoulder_center = (l_shoulder + r_shoulder) / 2
hip_center = (l_hip + r_hip) / 2
# Scale around center
pose_center_x = (shoulder_center[0] + hip_center[0]) / 2
pose_center_y = (shoulder_center[1] + hip_center[1]) / 2
char_pose[:, 0] = (char_pose[:, 0] - pose_center_x) * scale
char_pose[:, 1] = (char_pose[:, 1] - pose_center_y) * scale
char_pose[:, 2] = char_ref['depth_z']
# Flip Y
char_pose[:, 1] = -char_pose[:, 1]
# Position
char_pose[:, 0] = char_pose[:, 0] + char_ref['center_x']
# Floor anchor
pose_floor_y = np.max(char_pose[:, 1])
floor_offset = char_ref['floor_y'] - pose_floor_y
char_pose[:, 1] = char_pose[:, 1] + floor_offset
all_joints.append(char_pose.astype(np.float32))
frame_joints = np.concatenate(all_joints, axis=0)
output_poses.append(frame_joints)
# Blend from reference pose over transition frames
if reference_pose is not None and len(output_poses) > 0:
ref_joints = reference_pose['joints'].numpy()
blend_frames = min(transition_frames, len(output_poses))
for i in range(blend_frames):
t = i / blend_frames
# Smooth easing
t = t * t * (3 - 2 * t)
output_poses[i] = (1 - t) * ref_joints + t * output_poses[i]
# Build limb_seq and colors for all characters
full_limb_seq = []
full_colors = []
for i in range(char_count):
offset = i * 18
for (s, e) in LIMB_SEQ:
full_limb_seq.append((s + offset, e + offset))
full_colors.extend(BONE_COLORS)
return ({
"poses": output_poses,
"limb_seq": full_limb_seq,
"bone_colors": full_colors
},)
# ============================================================================
# MAPPINGS
# ============================================================================
NODE_CLASS_MAPPINGS = {
"SCAILAudioFeatureExtractor": SCAILAudioFeatureExtractor,
"SCAILBasePoseGenerator": SCAILBasePoseGenerator,
"SCAILBeatDetector": SCAILBeatDetector,
"SCAILBeatDrivenPose": SCAILBeatDrivenPose,
"SCAILPoseRenderer": SCAILPoseRenderer,
"SCAILPoseFromDWPose": SCAILPoseFromDWPose,
"SCAILAlignPoseToReference": SCAILAlignPoseToReference,
"SCAILAISTLibraryLoader": SCAILAISTLibraryLoader,
"SCAILAISTBeatDance": SCAILAISTBeatDance,
"SCAILAISTChunkPreview": SCAILAISTChunkPreview,
"SCAILAISTFullLoader": SCAILAISTFullLoader,
"SCAILAISTFullSequence": SCAILAISTFullSequence,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SCAILAudioFeatureExtractor": "SCAIL Audio Features",
"SCAILBasePoseGenerator": "SCAIL Base Pose",
"SCAILBeatDetector": "SCAIL Beat Detect",
"SCAILBeatDrivenPose": "SCAIL Beat-Driven Dance",
"SCAILPoseRenderer": "SCAIL Pose Render",
"SCAILPoseFromDWPose": "SCAIL Pose from DWPose",
"SCAILAlignPoseToReference": "SCAIL Align to Reference",
"SCAILAISTLibraryLoader": "SCAIL AIST Library",
"SCAILAISTBeatDance": "SCAIL AIST Beat Dance",
"SCAILAISTChunkPreview": "SCAIL AIST Preview",
"SCAILAISTFullLoader": "SCAIL AIST Full Library",
"SCAILAISTFullSequence": "SCAIL AIST Full Sequence",
}