From 2770397dea9246c09763f5a302e7d317264dd948 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Wed, 19 Aug 2026 15:46:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(pray):=20Pray01=20avatar=5Fpray=20clip=20?= =?UTF-8?q?=E2=80=94=20Kneel(lower,held)=20+=20Praying(upper,loop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- exchange/converted-glb/pray_01.glb | 3 + tools/merge_hold.py | 96 ++++++++++++++++ tools/merge_layer.py | 172 +++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 exchange/converted-glb/pray_01.glb create mode 100644 tools/merge_hold.py create mode 100644 tools/merge_layer.py diff --git a/exchange/converted-glb/pray_01.glb b/exchange/converted-glb/pray_01.glb new file mode 100644 index 0000000..ce35fc8 --- /dev/null +++ b/exchange/converted-glb/pray_01.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce4b78d0a4e85aa95095a0d7cd98f4d4b820ba1c252d732dca69c772edcbbf0c +size 214124 diff --git a/tools/merge_hold.py b/tools/merge_hold.py new file mode 100644 index 0000000..4aeaa15 --- /dev/null +++ b/tools/merge_hold.py @@ -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() diff --git a/tools/merge_layer.py b/tools/merge_layer.py new file mode 100644 index 0000000..3fca5aa --- /dev/null +++ b/tools/merge_layer.py @@ -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()