feat(anim): ladder climb-down dismount clip + reverse/trim tools
Reversed Mixamo "Start Climbing Ladder", retargeted to the UAL rig, then trimmed to the step-off tail → LadderClimbDown (35f/1.13s): starts on the ladder, pushes off, settles standing. Ships the full reversed take too (_full) as a re-trim source. New tools: - reverse_clip.py — pure time-reverse of a clip (30fps-pinned, sibling of pingpong_bake) - trim_clip.py — keep a frame window, re-based to 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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 <in.glb> --out <out.glb> --name <NewClipName>
|
||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -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 <in.glb> --out <out.glb> --name <NewClipName> --start <f> --end <f>
|
||||||
|
"""
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user