bvh-to-fbx.py applies a --frames range it is handed and chooses none

Optional '--frames A B' after the clip name sets the scene's frame range to A..B
(Blender frames, from 1); outside the take, or malformed, it exits 4 or 3 and writes
nothing. Without it the whole take, as before. The test's BVH builder is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-25 08:55:43 +01:00
commit 303cb28683
2 changed files with 57 additions and 14 deletions

View file

@ -1,6 +1,9 @@
# Converts one Kimodo standard-T-pose BVH to one FBX. Blender converts formats and nothing else: no retarget, no
# rename, no key reduction, no resample, no root-motion bake. Run by the daemon (scripts/vili-daemon.mjs exportClip):
# blender -b --factory-startup --python-use-system-env --python-exit-code 1 --python bvh-to-fbx.py -- <bvh> <fbx> <clipName>
# rename, no key reduction, no resample, no root-motion bake. It applies the frame range it is handed and chooses
# none: the whole take unless the daemon passes --frames. Run by the daemon (scripts/vili-daemon.mjs exportClip):
# blender -b --factory-startup --python-use-system-env --python-exit-code 1 --python bvh-to-fbx.py -- <bvh> <fbx> <clipName> [--frames <A> <B>]
# Frames are Blender's, counted from 1: the BVH's frame k is Blender's k + 1. Exit 3 is an argument or a hash seed it
# will not take, exit 4 a range outside the take.
# with PYTHONHASHSEED=0 in the environment. The settings are Blender API parameters, cross-checked against the
# 'unity' preset of wifimetas/kimodo-mcp blender_export.py (Apache-2.0, 83b5361); no code is copied.
import datetime
@ -15,7 +18,9 @@ import bpy
if sys.flags.hash_randomization != 0:
sys.exit(3)
bvh, fbx, clip = sys.argv[sys.argv.index("--") + 1:]
bvh, fbx, clip, *window = sys.argv[sys.argv.index("--") + 1:]
if window and not (len(window) == 3 and window[0] == "--frames" and all(arg.isdigit() for arg in window[1:])):
sys.exit(3)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
@ -38,6 +43,10 @@ action = armature.animation_data.action
# The FBX take takes the scene's name.
armature.name = armature.data.name = action.name = scene.name = clip
first, last = (int(frame) for frame in action.frame_range)
if window:
first, last = int(window[1]), int(window[2])
if not (int(action.frame_range[0]) <= first < last <= int(action.frame_range[1])):
sys.exit(4)
scene.frame_start, scene.frame_end = first, last
bpy.ops.object.select_all(action="DESELECT")

View file

@ -155,6 +155,26 @@ class BvhToFbx(unittest.TestCase):
line = next(line for line in run.stdout.splitlines() if line.startswith("INSPECT "))
return json.loads(line[len("INSPECT "):])
def walk(self, name, frames):
"""A BVH of the rest pose's skeleton whose Hips travel one metre, accelerating, and whose Spine1 is turned."""
text = self.rest.read_text()
header = text[:text.index("MOTION")]
channels = sum(int(n) for n in re.findall(r"CHANNELS\s+(\d+)", header))
rows = []
for frame in range(frames):
row = ["0"] * channels
row[8] = repr(100.0 * (frame / (frames - 1)) ** 2) # Hips Zposition, cm: one metre, accelerating
row[12], row[13], row[14] = "30", "20", "40" # Spine1 Z, Y, X rotation: the Euler order matters
rows.append(" ".join(row))
bvh = self.dir / f"{name}.bvh"
bvh.write_text(f"{header}MOTION
Frames: {frames}
Frame Time: 0.0333333
" + "
".join(rows) + "
")
return bvh
def test_rest_pose_keeps_the_bvh_joint_names_and_height(self):
fbx = self.dir / "rest.fbx"
run = convert(self.rest, fbx, "SOMA77_Rest")
@ -189,18 +209,8 @@ class BvhToFbx(unittest.TestCase):
self.assertFalse(target.exists())
def test_a_clip_keeps_its_travel_timing_pose_and_name(self):
text = self.rest.read_text()
header = text[:text.index("MOTION")]
channels = sum(int(n) for n in re.findall(r"CHANNELS\s+(\d+)", header))
frames = 31
rows = []
for frame in range(frames):
row = ["0"] * channels
row[8] = repr(100.0 * (frame / (frames - 1)) ** 2) # Hips Zposition, cm: one metre, accelerating
row[12], row[13], row[14] = "30", "20", "40" # Spine1 Z, Y, X rotation: the Euler order matters
rows.append(" ".join(row))
bvh = self.dir / "walk.bvh"
bvh.write_text(f"{header}MOTION\nFrames: {frames}\nFrame Time: 0.0333333\n" + "\n".join(rows) + "\n")
bvh = self.walk("walk", frames)
fbx = self.dir / "walk.fbx"
run = convert(bvh, fbx, "walk")
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
@ -222,6 +232,30 @@ class BvhToFbx(unittest.TestCase):
for got, want in zip(seen["head_from_hips"], seen["reference_head_from_hips"]):
self.assertAlmostEqual(got, want, delta=0.002, msg="the rotated pose is Blender's own native reading of the BVH")
def test_frames_exports_that_range_and_nothing_else(self):
bvh = self.walk("long", 61)
fbx = self.dir / "window.fbx"
run = blender("--python", str(SCRIPT), "--", str(bvh), str(fbx), "window", "--frames", "5", "40")
self.assertEqual(run.returncode, 0, run.stderr[-2000:])
self.assertEqual(result(run), {"bones": 78, "frames": [5, 40], "fps": 30})
seen = self.inspect(fbx, bvh)
# The BVH's frame k is Blender's k + 1, so frames 5..40 are BVH rows 4..39 and Hips z is 100 (k/60)^2 cm.
self.assertAlmostEqual(seen["hips_travel"], (39 ** 2 - 4 ** 2) / 60 ** 2, delta=0.002, msg=json.dumps(seen))
self.assertEqual(set(fbx_key_counts(fbx)), {36}, "one key per frame of the window")
whole = self.dir / "whole.fbx"
self.assertEqual(result(convert(bvh, whole, "whole"))["frames"], [1, 61], "without --frames the take is whole")
def test_frames_outside_the_take_or_malformed_exit_without_a_file(self):
bvh = self.walk("short", 31)
for index, (frames, exit_code) in enumerate((
(("0", "10"), 4), (("5", "32"), 4), (("10", "10"), 4), (("20", "10"), 4),
(("5",), 3), (("a", "9"), 3), (("-1", "9"), 3),
)):
fbx = self.dir / f"refused{index}.fbx"
run = blender("--python", str(SCRIPT), "--", str(bvh), str(fbx), "refused", "--frames", *frames)
self.assertEqual(run.returncode, exit_code, frames)
self.assertFalse(fbx.exists(), frames)
if __name__ == "__main__":
unittest.main()