diff --git a/exchange/converted-glb/ladder_climb_down.glb b/exchange/converted-glb/ladder_climb_down.glb new file mode 100644 index 0000000..b8d415b --- /dev/null +++ b/exchange/converted-glb/ladder_climb_down.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:094183073b56d5f42a5c9ebadbccafb3250d8859cac8f2573a960bf8154dce0a +size 154108 diff --git a/exchange/converted-glb/ladder_climb_down_full.glb b/exchange/converted-glb/ladder_climb_down_full.glb new file mode 100644 index 0000000..5b84e29 --- /dev/null +++ b/exchange/converted-glb/ladder_climb_down_full.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcc762141ce5f419c3a028bcacc1c095ce9ae7f47305f392eeb4d5f763420f35 +size 224572 diff --git a/tools/reverse_clip.py b/tools/reverse_clip.py new file mode 100644 index 0000000..4c8bd5a --- /dev/null +++ b/tools/reverse_clip.py @@ -0,0 +1,94 @@ +"""reverse_clip.py — play a clip backwards and rename it. + +Pure-reverse sibling of tools/pingpong_bake.py: samples the source clip densely +0..N, then rewrites each fcurve so output frame k holds the source value at frame +N-k. The last frame becomes the source's first pose — so a "mount / start" motion +becomes its "dismount / end" (e.g. Mixamo 'Start Climbing Ladder' reversed → the +ladder climb-DOWN / step-off). Root translation reverses with the rotations because +we sample the baked fcurve values, not the pose graph. + +Usage: + blender --background --python tools/reverse_clip.py -- \ + --src --out --name +""" +import bpy, sys, argparse + +def parse(): + argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--src", required=True) + p.add_argument("--out", required=True) + p.add_argument("--name", required=True, help="clip name baked into the output") + return p.parse_args(argv) + +def action_fcurves(a): + """All fcurves across Blender 5 slotted-action layers/strips (legacy fallback).""" + 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 + +def main(): + a = parse() + bpy.ops.wm.read_factory_settings(use_empty=True) + # Factory settings reset fps to 24; the whole pipeline is 30fps (mixamo_retarget sets + # scn.render.fps=30). Import at 24 would resample a 2.03s clip to 50 keys instead of 62 + # — same motion, coarser samples. Pin 30 so the reversed clip matches the source density. + bpy.context.scene.render.fps = 30 + bpy.ops.import_scene.gltf(filepath=a.src) + arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE') + act = arm.animation_data.action + if getattr(act, "slots", None) and arm.animation_data.action_slot is None: + arm.animation_data.action_slot = act.slots[0] + f0, f1 = int(act.frame_range[0]), int(act.frame_range[1]) + n = f1 - f0 + print(f"[reverse] src clip '{act.name}' frames {f0}..{f1} -> reversed 0..{n}") + + # rewrite every fcurve to a clean dense 0..N ramp holding mirrored values + for fc in action_fcurves(act): + vals = [fc.evaluate(f0 + k) for k in range(n + 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[n - k]) # reversed: out frame k = src frame N-k + kp.interpolation = 'LINEAR' + fc.update() + + act.name = a.name + + # drop NLA tracks that came in with the import — only our reversed track may export + for t in list(arm.animation_data.nla_tracks): + arm.animation_data.nla_tracks.remove(t) + + # single NLA track named = clip name (same convention as cc_retarget.py) + track = arm.animation_data.nla_tracks.new() + track.name = a.name + strip = track.strips.new(a.name, 0, act) + strip.name = a.name + arm.animation_data.action = None + + for o in [o for o in bpy.data.objects if o.type == 'MESH']: + bpy.data.objects.remove(o, do_unlink=True) + + bpy.context.scene.frame_start = 0 + bpy.context.scene.frame_end = n + bpy.ops.export_scene.gltf( + filepath=a.out, + export_animation_mode="NLA_TRACKS", + export_force_sampling=True, + export_optimize_animation_size=False, + ) + print(f"[reverse] EXPORTED {a.out} clip '{a.name}' ({n+1} frames)") + +if __name__ == "__main__": + main() diff --git a/tools/trim_clip.py b/tools/trim_clip.py new file mode 100644 index 0000000..7cbabd5 --- /dev/null +++ b/tools/trim_clip.py @@ -0,0 +1,89 @@ +"""trim_clip.py — keep a frame window of a clip and re-base it to frame 0. + +Samples every fcurve across [--start .. --end] (inclusive, source frames) and rewrites +the action to hold just that window at frames 0..(end-start). Use to lift a sub-beat out +of a longer take — e.g. keep only the dismount tail of a ladder clip so the descent loop +owns the vertical travel and the trimmed clip is purely the step-off. + +Usage: + blender --background --python tools/trim_clip.py -- \ + --src --out --name --start --end +""" +import bpy, sys, argparse + +def parse(): + argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--src", required=True) + p.add_argument("--out", required=True) + p.add_argument("--name", required=True, help="clip name baked into the output") + p.add_argument("--start", type=int, required=True, help="first source frame to keep") + p.add_argument("--end", type=int, required=True, help="last source frame to keep (inclusive)") + return p.parse_args(argv) + +def action_fcurves(a): + """All fcurves across Blender 5 slotted-action layers/strips (legacy fallback).""" + 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 + +def main(): + a = parse() + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.scene.render.fps = 30 # pipeline is 30fps (see reverse_clip.py) + bpy.ops.import_scene.gltf(filepath=a.src) + arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE') + act = arm.animation_data.action + if getattr(act, "slots", None) and arm.animation_data.action_slot is None: + arm.animation_data.action_slot = act.slots[0] + s, e = a.start, a.end + n = e - s + if n <= 0: + raise SystemExit(f"[trim] bad window {s}..{e}") + print(f"[trim] src '{act.name}' keep frames {s}..{e} -> 0..{n}") + + for fc in action_fcurves(act): + vals = [fc.evaluate(s + k) for k in range(n + 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() + + act.name = a.name + + for t in list(arm.animation_data.nla_tracks): + arm.animation_data.nla_tracks.remove(t) + track = arm.animation_data.nla_tracks.new() + track.name = a.name + strip = track.strips.new(a.name, 0, act) + strip.name = a.name + arm.animation_data.action = None + + for o in [o for o in bpy.data.objects if o.type == 'MESH']: + bpy.data.objects.remove(o, do_unlink=True) + + bpy.context.scene.frame_start = 0 + bpy.context.scene.frame_end = n + bpy.ops.export_scene.gltf( + filepath=a.out, + export_animation_mode="NLA_TRACKS", + export_force_sampling=True, + export_optimize_animation_size=False, + ) + print(f"[trim] EXPORTED {a.out} clip '{a.name}' ({n+1} frames)") + +if __name__ == "__main__": + main()