"""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()