feat(pray): Pray01 avatar_pray clip — Kneel(lower,held) + Praying(upper,loop)
Fills Ozan order-board row #4 (avatar_pray): kneel toward statue, hands together, hold. Mixamo Kneel held at deepest-kneel frame (184) for the legs + Praying looped forward on the upper (rotation-only graft), retargeted to the shared Quaternius rig. Loop QC SMOOTH (0.00deg seam). Adds tools/merge_layer.py + tools/merge_hold.py (upper/lower track compositing: layer and hold modes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""merge_hold.py — Pray01 v2: kneeling-prayer HOLD (loop-clean).
|
||||
|
||||
Lower body = Kneel held at its deepest-kneel frame (static, character stays kneeling).
|
||||
Upper body = Praying looped FORWARD (rotation only) for gentle life — hands stay clasped.
|
||||
Loops cleanly: lower is static, upper spans a whole number of Praying loop periods.
|
||||
Matches Ozan's spec (kneel, hands together, hold ~2-3s) and the looping-pray pattern the
|
||||
Kevin Reverence01_Loop stand-in used.
|
||||
"""
|
||||
import bpy, sys, re, argparse
|
||||
|
||||
def parse():
|
||||
argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--upper", required=True) # Praying
|
||||
p.add_argument("--lower", required=True) # Kneel
|
||||
p.add_argument("--hold", type=int, required=True) # deepest-kneel frame in Kneel
|
||||
p.add_argument("--nframes", type=int, default=72) # output length (72=2.4s=2 Praying loops)
|
||||
p.add_argument("--glb", required=True)
|
||||
p.add_argument("--name", default="PrayHold")
|
||||
return p.parse_args(argv)
|
||||
|
||||
def action_fcurves(a):
|
||||
if hasattr(a, "fcurves") and getattr(a, "fcurves", None) is not None:
|
||||
return list(a.fcurves)
|
||||
fcs = []
|
||||
for layer in getattr(a, "layers", []):
|
||||
for strip in layer.strips:
|
||||
for bag in strip.channelbags:
|
||||
fcs.extend(bag.fcurves)
|
||||
return fcs
|
||||
|
||||
UPPER_KEYS = ("Spine", "Neck", "Head", "Shoulder", "Arm", "Hand")
|
||||
def bone_of(dp):
|
||||
m = re.search(r'pose\.bones\["([^"]+)"\]', dp); return m.group(1) if m else ""
|
||||
def is_upper(b):
|
||||
b = b.split(":")[-1]; return any(k in b for k in UPPER_KEYS)
|
||||
|
||||
def main():
|
||||
a = parse()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scn = bpy.context.scene; scn.render.fps = 30
|
||||
|
||||
bpy.ops.import_scene.fbx(filepath=a.lower)
|
||||
lower_arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
||||
lower_act = lower_arm.animation_data.action
|
||||
if getattr(lower_act, "slots", None) and lower_arm.animation_data.action_slot is None:
|
||||
lower_arm.animation_data.action_slot = lower_act.slots[0]
|
||||
|
||||
pre = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.fbx(filepath=a.upper)
|
||||
upper_arm = next(o for o in (set(bpy.data.objects)-pre) if o.type == 'ARMATURE')
|
||||
upper_act = upper_arm.animation_data.action
|
||||
|
||||
pf0 = int(upper_act.frame_range[0]); pf1 = int(upper_act.frame_range[1])
|
||||
period = pf1 - pf0 + 1
|
||||
N = a.nframes
|
||||
print(f"[hold] lower held @frame {a.hold} | upper forward-loop period {period} | out 0..{N}")
|
||||
|
||||
umap = {(fc.data_path, fc.array_index): fc for fc in action_fcurves(upper_act)}
|
||||
|
||||
up_ct = lo_ct = 0
|
||||
for fc in action_fcurves(lower_act):
|
||||
bone = bone_of(fc.data_path)
|
||||
is_rot = fc.data_path.endswith("rotation_quaternion") or fc.data_path.endswith("rotation_euler")
|
||||
upper = bone and is_upper(bone) and is_rot and (fc.data_path, fc.array_index) in umap
|
||||
if upper:
|
||||
src = umap[(fc.data_path, fc.array_index)]
|
||||
vals = [src.evaluate(pf0 + (f % period)) for f in range(N+1)] # forward loop
|
||||
up_ct += 1
|
||||
else:
|
||||
hv = fc.evaluate(a.hold) # static hold
|
||||
vals = [hv] * (N+1)
|
||||
lo_ct += 1
|
||||
need = N + 1; have = len(fc.keyframe_points)
|
||||
if have < need: fc.keyframe_points.add(need - have)
|
||||
elif have > need:
|
||||
for _ in range(have - need): fc.keyframe_points.remove(fc.keyframe_points[-1], fast=True)
|
||||
for k in range(need):
|
||||
kp = fc.keyframe_points[k]; kp.co = (k, vals[k]); kp.interpolation = 'LINEAR'
|
||||
fc.update()
|
||||
print(f"[hold] {up_ct} upper (Praying loop) + {lo_ct} lower/base (Kneel held) fcurves")
|
||||
|
||||
lower_act.name = a.name
|
||||
bpy.data.objects.remove(upper_arm, do_unlink=True)
|
||||
for t in list(lower_arm.animation_data.nla_tracks):
|
||||
lower_arm.animation_data.nla_tracks.remove(t)
|
||||
track = lower_arm.animation_data.nla_tracks.new(); track.name = a.name
|
||||
track.strips.new(a.name, 0, lower_act).name = a.name
|
||||
lower_arm.animation_data.action = None
|
||||
scn.frame_start = 0; scn.frame_end = N
|
||||
bpy.ops.export_scene.gltf(filepath=a.glb, export_animation_mode="NLA_TRACKS",
|
||||
export_force_sampling=True, export_optimize_animation_size=False)
|
||||
print(f"[hold] EXPORTED {a.glb} clip '{a.name}' ({N+1} frames)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""merge_layer.py — layer upper body (Praying, reversed+looped) onto lower body (Kneel).
|
||||
|
||||
Both inputs are identical mixamorig skeletons. We take the upper-body bone tracks
|
||||
(Spine chain, neck/head, both arms + hands/fingers) from Praying played BACKWARDS and
|
||||
LOOPED, and keep the lower body (Hips, legs, feet) from Kneel. In-place fcurve rewrite
|
||||
(same proven pattern as tools/reverse_clip.py). Then export a clean GLB and render a
|
||||
color-coded stick-figure MP4 preview (upper = cyan, lower = orange).
|
||||
"""
|
||||
import bpy, sys, re, argparse
|
||||
from mathutils import Vector
|
||||
|
||||
def parse():
|
||||
argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else []
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--upper", required=True) # Praying (reversed+looped)
|
||||
p.add_argument("--lower", required=True) # Kneel
|
||||
p.add_argument("--glb", required=True)
|
||||
p.add_argument("--mp4", required=True)
|
||||
p.add_argument("--name", default="PrayKneel")
|
||||
return p.parse_args(argv)
|
||||
|
||||
def action_fcurves(a):
|
||||
if hasattr(a, "fcurves") and getattr(a, "fcurves", None) is not None:
|
||||
return list(a.fcurves)
|
||||
fcs = []
|
||||
for layer in getattr(a, "layers", []):
|
||||
for strip in layer.strips:
|
||||
for bag in strip.channelbags:
|
||||
fcs.extend(bag.fcurves)
|
||||
return fcs
|
||||
|
||||
UPPER_KEYS = ("Spine", "Neck", "Head", "Shoulder", "Arm", "Hand") # Arm covers ForeArm; Hand covers fingers
|
||||
def bone_of(dp):
|
||||
m = re.search(r'pose\.bones\["([^"]+)"\]', dp)
|
||||
return m.group(1) if m else ""
|
||||
def is_upper(bone):
|
||||
b = bone.split(":")[-1]
|
||||
return any(k in b for k in UPPER_KEYS)
|
||||
|
||||
def main():
|
||||
a = parse()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scn = bpy.context.scene
|
||||
scn.render.fps = 30
|
||||
|
||||
# import lower (Kneel) first -> base armature we keep
|
||||
bpy.ops.import_scene.fbx(filepath=a.lower)
|
||||
lower_arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
||||
lower_act = lower_arm.animation_data.action
|
||||
if getattr(lower_act, "slots", None) and lower_arm.animation_data.action_slot is None:
|
||||
lower_arm.animation_data.action_slot = lower_act.slots[0]
|
||||
|
||||
# import upper (Praying)
|
||||
pre = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.fbx(filepath=a.upper)
|
||||
upper_arm = next(o for o in (set(bpy.data.objects)-pre) if o.type == 'ARMATURE')
|
||||
upper_act = upper_arm.animation_data.action
|
||||
|
||||
kf0, kf1 = int(lower_act.frame_range[0]), int(lower_act.frame_range[1])
|
||||
kn = kf1 - kf0 # lower length
|
||||
pf0, pf1 = int(upper_act.frame_range[0]), int(upper_act.frame_range[1])
|
||||
pn = pf1 - pf0 # upper length
|
||||
period = pn + 1
|
||||
N = kn
|
||||
print(f"[merge] lower {kf0}..{kf1} (N={kn}) | upper {pf0}..{pf1} (period={period}) -> merged 0..{N}")
|
||||
|
||||
# upper source fcurves keyed by (data_path, array_index)
|
||||
umap = {(fc.data_path, fc.array_index): fc for fc in action_fcurves(upper_act)}
|
||||
|
||||
upper_ct = lower_ct = 0
|
||||
for fc in action_fcurves(lower_act):
|
||||
bone = bone_of(fc.data_path)
|
||||
is_rot = fc.data_path.endswith("rotation_quaternion") or fc.data_path.endswith("rotation_euler")
|
||||
# graft ONLY rotation from Praying — non-root bone location is a rest-length offset;
|
||||
# overwriting it detaches the torso from the hips. Location/scale stay from Kneel.
|
||||
upper = bone and is_upper(bone) and is_rot and (fc.data_path, fc.array_index) in umap
|
||||
if upper:
|
||||
src = umap[(fc.data_path, fc.array_index)]
|
||||
# reversed within each loop: local t=f%period, reversed = pn-t, sample pf0+reversed
|
||||
vals = [src.evaluate(pf0 + (pn - (f % period))) for f in range(N+1)]
|
||||
upper_ct += 1
|
||||
else:
|
||||
vals = [fc.evaluate(kf0 + min(f, kn)) for f in range(N+1)]
|
||||
lower_ct += 1
|
||||
need = N + 1
|
||||
have = len(fc.keyframe_points)
|
||||
if have < need:
|
||||
fc.keyframe_points.add(need - have)
|
||||
elif have > need:
|
||||
for _ in range(have - need):
|
||||
fc.keyframe_points.remove(fc.keyframe_points[-1], fast=True)
|
||||
for k in range(need):
|
||||
kp = fc.keyframe_points[k]
|
||||
kp.co = (k, vals[k]); kp.interpolation = 'LINEAR'
|
||||
fc.update()
|
||||
print(f"[merge] rewrote {upper_ct} upper (Praying) + {lower_ct} lower/base (Kneel) fcurves")
|
||||
|
||||
lower_act.name = a.name
|
||||
|
||||
# drop upper armature entirely
|
||||
for o in [upper_arm]:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
# single NLA track = clip name (pipeline convention)
|
||||
for t in list(lower_arm.animation_data.nla_tracks):
|
||||
lower_arm.animation_data.nla_tracks.remove(t)
|
||||
track = lower_arm.animation_data.nla_tracks.new(); track.name = a.name
|
||||
track.strips.new(a.name, 0, lower_act).name = a.name
|
||||
lower_arm.animation_data.action = None
|
||||
|
||||
scn.frame_start = 0; scn.frame_end = N
|
||||
bpy.ops.export_scene.gltf(filepath=a.glb, export_animation_mode="NLA_TRACKS",
|
||||
export_force_sampling=True, export_optimize_animation_size=False)
|
||||
print(f"[merge] EXPORTED {a.glb} clip '{a.name}' ({N+1} frames)")
|
||||
|
||||
# ---- stick-figure preview ----
|
||||
lower_arm.animation_data.action = lower_act # re-attach for evaluation
|
||||
if getattr(lower_act, "slots", None) and lower_arm.animation_data.action_slot is None:
|
||||
lower_arm.animation_data.action_slot = lower_act.slots[0]
|
||||
|
||||
mat_up = bpy.data.materials.new("up"); mat_up.diffuse_color = (0.15, 0.75, 0.95, 1)
|
||||
mat_lo = bpy.data.materials.new("lo"); mat_lo.diffuse_color = (0.95, 0.5, 0.12, 1)
|
||||
|
||||
# scale reference from bone lengths
|
||||
scn.frame_set(0)
|
||||
heads = [lower_arm.matrix_world @ pb.head for pb in lower_arm.pose.bones]
|
||||
size = max((max(v[i] for v in heads) - min(v[i] for v in heads)) for i in range(3)) or 1.0
|
||||
rad = size * 0.012
|
||||
|
||||
for pb in lower_arm.pose.bones:
|
||||
if pb.bone.length < 1e-4:
|
||||
continue
|
||||
bpy.ops.mesh.primitive_cylinder_add(radius=rad, depth=1.0, vertices=8)
|
||||
cyl = bpy.context.active_object
|
||||
cyl.rotation_euler = (1.5708, 0, 0) # cylinder long axis Z -> Y so STRETCH_TO (Y) aligns
|
||||
bpy.ops.object.transform_apply(rotation=True)
|
||||
up = is_upper(pb.name)
|
||||
cyl.data.materials.append(mat_up if up else mat_lo)
|
||||
cyl.color = (mat_up.diffuse_color if up else mat_lo.diffuse_color)
|
||||
c = cyl.constraints.new('COPY_LOCATION'); c.target = lower_arm; c.subtarget = pb.name; c.head_tail = 0.0
|
||||
s = cyl.constraints.new('STRETCH_TO'); s.target = lower_arm; s.subtarget = pb.name; s.head_tail = 1.0
|
||||
s.rest_length = 1.0; s.volume = 'NO_VOLUME'
|
||||
|
||||
# camera framing over the whole motion
|
||||
mn = Vector(( 1e9, 1e9, 1e9)); mx = Vector((-1e9,-1e9,-1e9))
|
||||
for f in (0, N//4, N//2, 3*N//4, N):
|
||||
scn.frame_set(f)
|
||||
for pb in lower_arm.pose.bones:
|
||||
for p in (lower_arm.matrix_world @ pb.head, lower_arm.matrix_world @ pb.tail):
|
||||
for i in range(3):
|
||||
mn[i] = min(mn[i], p[i]); mx[i] = max(mx[i], p[i])
|
||||
ctr = (mn + mx) * 0.5
|
||||
span = max((mx - mn)[i] for i in range(3))
|
||||
cam_data = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", cam_data)
|
||||
scn.collection.objects.link(cam)
|
||||
d = span * 1.9
|
||||
cam.location = ctr + Vector((d*0.35, -d, d*0.10))
|
||||
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
scn.camera = cam
|
||||
|
||||
scn.render.engine = 'BLENDER_WORKBENCH'
|
||||
scn.display.shading.light = 'STUDIO'
|
||||
scn.display.shading.color_type = 'OBJECT'
|
||||
scn.render.resolution_x = 640; scn.render.resolution_y = 760
|
||||
scn.render.image_settings.file_format = 'PNG'
|
||||
scn.render.filepath = a.mp4 # here a.mp4 is a frame-dir prefix, e.g. .../frames/f_
|
||||
scn.frame_start = 0; scn.frame_end = N
|
||||
bpy.ops.render.render(animation=True)
|
||||
print(f"[merge] RENDERED frames to {a.mp4}####")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user