BVH refactor

This commit is contained in:
kijai 2026-06-15 17:58:22 +03:00
parent cf7c5a0bde
commit 6280ba29a3
3 changed files with 224 additions and 254 deletions

View File

@ -713,6 +713,12 @@ class File3DFBX(ComfyTypeIO):
Type = File3D
@comfytype(io_type="FILE_3D_BVH")
class File3DBVH(ComfyTypeIO):
"""BVH format 3D file - skeletal motion capture animation (no geometry)."""
Type = File3D
@comfytype(io_type="FILE_3D_OBJ")
class File3DOBJ(ComfyTypeIO):
"""OBJ format 3D file - simple geometry format."""
@ -2349,6 +2355,7 @@ __all__ = [
"File3DGLB",
"File3DGLTF",
"File3DFBX",
"File3DBVH",
"File3DOBJ",
"File3DSTL",
"File3DUSDZ",

View File

@ -33,12 +33,7 @@ from comfy_extras.sam3d_body.utils import image_to_uint8
SAM3TrackData = io.Custom("SAM3_TRACK_DATA")
# MHRPoseData = SAM3DBody_Predict's native output (carries mhr_model_params,
# shape_params, expr_params, MHR70 keypoint layout, canonical_colors keyed to
# MHR mesh, hand_vert_mask from MHR LBS). The export-side consumers
# (BuildPoseGLB / SavePoseBVH in comfy_extras/nodes_save_3d.py) also accept
# KIMODO_POSE_DATA via a MultiType union — those types are mirrored there.
MHRPoseData = io.Custom("MHR_POSE_DATA")
MHRPoseData = io.Custom("MHR_POSE_DATA") # mhr_model_params, shape_params, expr_params, MHR70 keypoint layout, canonical_colors keyed to MHR mesh, hand_vert_mask from MHR LBS).
SAM3DBodyModel = io.Custom("SAM3D_BODY_MODEL")
# Loader
@ -151,7 +146,7 @@ class SAM3DBody_Predict(io.ComfyNode):
),
),
io.Int.Input(
"chunk_size", #TODO: automate?
"batch_size", #TODO: automate?
default=64, min=1, max=512, step=1, advanced=True,
tooltip=(
"Max frames to process as a batch. Larger values utilize more VRAM for faster inference."
@ -162,7 +157,7 @@ class SAM3DBody_Predict(io.ComfyNode):
)
@classmethod
def execute(cls, sam3d_body_model, image, sam3_track_data=None, bboxes=None, run_hand_refinement=True, fov=0.0, chunk_size=64) -> io.NodeOutput:
def execute(cls, sam3d_body_model, image, track_data=None, bboxes=None, run_hand_refinement=True, fov=0.0, batch_size=64) -> io.NodeOutput:
comfy.model_management.load_model_gpu(sam3d_body_model)
inner: SAM3DBody = sam3d_body_model.model
@ -171,8 +166,8 @@ class SAM3DBody_Predict(io.ComfyNode):
# Precedence: SAM3 track (masks + boxes) > detector boxes > full-frame fallback.
per_frame_bboxes, per_frame_masks = (None, None)
if sam3_track_data is not None:
per_frame_bboxes, per_frame_masks = inputs_from_sam3_track(sam3_track_data, B, H, W)
if track_data is not None:
per_frame_bboxes, per_frame_masks = inputs_from_sam3_track(track_data, B, H, W)
if per_frame_bboxes is None and bboxes:
per_frame_bboxes = _per_frame_bboxes_from_detections(bboxes, B)
per_frame_masks = None
@ -209,7 +204,7 @@ class SAM3DBody_Predict(io.ComfyNode):
image_size, inference_type,
cam_int=cam_int,
pbar=pbar,
crops_per_chunk=int(chunk_size),
crops_per_chunk=int(batch_size),
)
else:
# Mixed K per frame — call the batched path once per frame.
@ -459,7 +454,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
io.Combo.Input(
"method",
options=["gaussian", "savgol"],
default="gaussian", advanced=True,
default="savgol", advanced=True,
tooltip=(
"gaussian: symmetric weighted average, best general-purpose smoother./n"
"savgol: sliding polynomial fit, preserves sharp peaks."
@ -471,7 +466,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
tooltip="Temporal window in frames (odd values).",
),
io.Float.Input(
"rotation_threshold_deg",
"rotation_threshold_degrees",
default=30.0, min=0.0, max=90.0, step=1.0, advanced=True,
tooltip=(
"Disables smoothing for this root rotation rate (degree/frame) to preserve fast spins. "
@ -484,7 +479,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
)
@classmethod
def execute(cls, mhr_pose_data, method, window, strength, rotation_threshold_deg) -> io.NodeOutput:
def execute(cls, mhr_pose_data, method, window, strength, rotation_threshold_degrees) -> io.NodeOutput:
if strength <= 0.0 or window <= 1:
return io.NodeOutput(mhr_pose_data)
@ -514,7 +509,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
smoothed = [list(f) for f in frames]
base_blend = float(strength)
rot_thresh = float(np.deg2rad(max(0.0, rotation_threshold_deg)))
rot_thresh = float(np.deg2rad(max(0.0, rotation_threshold_degrees)))
for pid in range(max_p):
valid = np.array([pid < len(f) for f in frames], dtype=bool)
@ -861,13 +856,7 @@ class SAM3DBody_Render(io.ComfyNode):
"camera_info", optional=True,
tooltip=(
"Free 6DOF camera override. When wired, the pose is re-projected through this camera "
"(position/target/zoom) instead of the predicted one. "
),
),
io.Float.Input(
"fov", default=0.0, min=0.0, max=170.0, step=0.5, advanced=True,
tooltip=(
"Override the vertical FoV of the camera_info. Ignored when camera_info is empty. 0 = keep the FoV of the camera_info."
"(position/target/zoom/rotation/FoV) instead of the predicted one. "
),
),
io.DynamicCombo.Input(
@ -893,7 +882,7 @@ class SAM3DBody_Render(io.ComfyNode):
@classmethod
def execute(cls, mhr_pose_data, background=None, width=0, height=0, camera_info=None, fov=0.0, render_style=None) -> io.NodeOutput:
def execute(cls, mhr_pose_data, background=None, width=0, height=0, camera_info=None, render_style=None) -> io.NodeOutput:
render_style = render_style or {"render_style": "mesh"}
mode_key = render_style.get("render_style", "mesh")
@ -912,7 +901,7 @@ class SAM3DBody_Render(io.ComfyNode):
px_scale = min(new_W / native_W, new_H / native_H)
if camera_info is not None:
mhr_pose_data = apply_camera_override(mhr_pose_data, camera_info, H, W, fov_deg=float(fov))
mhr_pose_data = apply_camera_override(mhr_pose_data, camera_info, H, W)
B = len(mhr_pose_data["frames"])
if B == 0:

View File

@ -342,6 +342,7 @@ class SaveGLB(IO.ComfyNode):
IO.File3DGLTF,
IO.File3DOBJ,
IO.File3DFBX,
IO.File3DBVH,
IO.File3DSTL,
IO.File3DUSDZ,
IO.File3DPLY,
@ -428,7 +429,7 @@ def rainbow_tilt_inputs():
def camera_translation_input():
"""Shared camera_translation combo (BuildPoseGLB + SavePoseBVH)."""
"""Shared camera_translation combo (Create 3D Animation glb + bvh paths)."""
return IO.Combo.Input(
"camera_translation",
options=["off", "centered", "absolute"],
@ -442,15 +443,16 @@ def camera_translation_input():
)
class BuildPoseGLB(IO.ComfyNode):
"""Convert pose_data to an in-memory animated GLB"""
class BuildPoseFile(IO.ComfyNode):
"""Build an animated GLB from pose data, or save it as a BVH mocap file."""
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="BuildPoseGLB",
display_name="Build Pose GLB",
description="Convert pose data to an animated GLB",
node_id="BuildPoseFile",
display_name="Create 3D Animation File",
description="Build an animated GLB from pose data, or save a BVH mocap file.",
search_aliases=["pose animation", "mocap", "glb", "bvh", "build pose file", "save pose file"],
category="3d",
inputs=[
IO.MultiType.Input(
@ -459,188 +461,208 @@ class BuildPoseGLB(IO.ComfyNode):
),
SAM3DBodyModel.Input("sam3d_body_model", optional=True),
IO.DynamicCombo.Input(
"mesh_style",
"format",
options=[
IO.DynamicCombo.Option("body_mesh", [
IO.DynamicCombo.Option("glb", [
IO.DynamicCombo.Input(
"bone_vis",
"mesh_style",
options=[
IO.DynamicCombo.Option("off", []),
IO.DynamicCombo.Option("octahedrons", [
IO.Float.Input(
"bone_vis_radius_m",
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True,
tooltip="Radius in m (sphere radius / octahedron half-width).",
IO.DynamicCombo.Option("body_mesh", [
IO.DynamicCombo.Input(
"bone_vis",
options=[
IO.DynamicCombo.Option("off", []),
IO.DynamicCombo.Option("octahedrons", [
IO.Float.Input(
"bone_vis_radius_m",
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True,
tooltip="Radius in m (sphere radius / octahedron half-width).",
),
IO.Combo.Input(
"bone_vis_color",
options=["white", "rainbow_y"],
default="rainbow_y",
tooltip=(
"Per-bone vertex colors (unlit material). "
"'white' = none, 'rainbow_y' = head→toe jet."
),
),
]),
],
tooltip=("Bone vis shape, rigidly skinned to each joint. "),
),
IO.Combo.Input(
"bone_vis_color",
options=["white", "rainbow_y"],
default="rainbow_y",
IO.DynamicCombo.Input(
"shader",
options=[
IO.DynamicCombo.Option("default", []),
IO.DynamicCombo.Option("rainbow", [
*rainbow_tilt_inputs(),
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
),
]),
IO.DynamicCombo.Option("rainbow_face_normal", [
*rainbow_tilt_inputs(),
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
),
]),
IO.DynamicCombo.Option("rainbow_face_semantic", [
*rainbow_tilt_inputs(),
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
),
]),
],
tooltip=(
"Per-bone vertex colors (unlit material). "
"'white' = none, 'rainbow_y' = head→toe jet."
"Bake per-vertex colors matching the Render node's shaders "
"(COLOR_0 + KHR_materials_unlit). 'default' = no colors."
),
),
]),
],
tooltip=("Bone vis shape, rigidly skinned to each joint. "),
),
IO.DynamicCombo.Input(
"shader",
options=[
IO.DynamicCombo.Option("default", []),
IO.DynamicCombo.Option("rainbow", [
*rainbow_tilt_inputs(),
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
IO.DynamicCombo.Option("bones_only", [
IO.DynamicCombo.Input(
"bone_vis",
options=[
IO.DynamicCombo.Option("octahedrons", [
IO.Float.Input(
"bone_vis_radius_m",
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True,
tooltip="Radius in m (sphere radius / octahedron half-width).",
),
IO.Combo.Input(
"bone_vis_color",
options=["white", "rainbow_y"],
default="rainbow_y",
tooltip=(
"Per-bone vertex colors (unlit material). "
"'white' = none, 'rainbow_y' = head→toe jet."
),
),
]),
],
tooltip=(
"Bone vis shape, rigidly skinned to each joint. "
"'octahedrons' = Blender-style directional bones (joint → "
"primary child)."
),
),
]),
IO.DynamicCombo.Option("rainbow_face_normal", [
*rainbow_tilt_inputs(),
IO.DynamicCombo.Option("openpose", [
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
"marker_radius_m", default=0.010, min=0.005, max=0.1, step=0.001, advanced=True,
tooltip="Sphere radius in m.",
),
]),
IO.DynamicCombo.Option("rainbow_face_semantic", [
*rainbow_tilt_inputs(),
IO.Float.Input(
"person_palette_falloff",
default=0.6, min=0.1, max=1.0, step=0.05,
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
"stick_radius_m", default=0.008, min=0.002, max=0.05, step=0.001, advanced=True,
tooltip="Limb half-width in m. Auto-clamped to bone_length x 0.1.",
),
IO.Boolean.Input(
"include_hands", default=False,
tooltip=(
"Append 21+21 OpenPose hands (wrist + 5 fingers x 4 joints, "
"base→tip) sourced from pred_keypoints_3d."
),
),
]),
],
tooltip=(
"Bake per-vertex colors matching the Render node's shaders "
"(COLOR_0 + KHR_materials_unlit). 'default' = no colors."
),
),
]),
IO.DynamicCombo.Option("bones_only", [
IO.DynamicCombo.Input(
"bone_vis",
options=[
IO.DynamicCombo.Option("octahedrons", [
IO.Float.Input(
"bone_vis_radius_m",
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True,
tooltip="Radius in m (sphere radius / octahedron half-width).",
"hand_marker_radius_m", default=0.005, min=0.001, max=0.1, step=0.001, advanced=True,
tooltip="Hand sphere radius in m.",
),
IO.Float.Input(
"hand_stick_radius_m", default=0.003, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand limb half-width in m.",
),
IO.Combo.Input(
"bone_vis_color",
options=["white", "rainbow_y"],
default="rainbow_y",
"face_style",
options=["disabled", "full", "eyes_mouth"],
default="disabled",
tooltip=(
"Per-bone vertex colors (unlit material). "
"'white' = none, 'rainbow_y' = head→toe jet."
"Face-contour landmarks sampled from pred_vertices at fixed "
"head-mesh vertex IDs (needs canonical_colors on pose_data). "
"'full' = all ~30 points; 'eyes_mouth' = eyes + outer lips only."
),
),
IO.Float.Input(
"face_marker_radius_m", default=0.0, min=0.0, max=0.05, step=0.0005, advanced=True,
tooltip="Face dot radius. 0 = auto = 0.3 x marker_radius_m.",
),
]),
IO.DynamicCombo.Option("scail", [
IO.Float.Input(
"stick_radius_m", default=0.022, min=0.002, max=0.1, step=0.001, advanced=True,
tooltip=(
"Cylinder radius in m. Bones are open cylinders at constant "
"radius; joint spheres (auto-sized to match) cap the open ends. "
"SCAIL reference = 0.0215 m."
),
),
IO.Float.Input(
"marker_radius_m", default=0.0, min=0.0, max=0.1, step=0.001, advanced=True,
tooltip="Joint sphere radius. 0 = auto = stick_radius_m (flush cap).",
),
IO.Float.Input(
"material_roughness", default=0.3, min=0.0, max=1.0, step=0.05, advanced=True,
tooltip="PBR roughness. SCAIL ref = 0.3. 1 = matte; 0 = chrome.",
),
IO.Boolean.Input(
"include_hands", default=False,
tooltip="Append 21+21 hand keypoints + capsule sticks per track.",
),
IO.Float.Input(
"hand_marker_radius_m", default=0.005, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand sphere radius in m.",
),
IO.Float.Input(
"hand_stick_radius_m", default=0.003, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand cylinder radius in m.",
),
IO.Combo.Input(
"face_style",
options=["disabled", "full", "eyes_mouth"],
default="disabled",
tooltip=(
"Face-contour landmarks sampled from pred_vertices (needs "
"canonical_colors on pose_data). 'full' = all ~30 points; "
"'eyes_mouth' = eyes + outer lips only."
),
),
]),
],
tooltip=(
"Bone vis shape, rigidly skinned to each joint. "
"'octahedrons' = Blender-style directional bones (joint → "
"primary child)."
"'body_mesh' = real Armature (127 bones, skinning, TRS keyframes, 72 face morphs; needs model). "
"'bones_only' = bone-shape primitives at each joint (preview armature). "
"'openpose' = OpenPose-18 3D skeleton from keypoints "
"'scail' = SCAIL 3D capsule rig (open cylinders capped flush by joint spheres)."
),
),
IO.Int.Input(
"bone_smooth_window",
default=0, min=0, max=51, step=2,
tooltip=(
"Gaussian smoothing window on per-bone rotation keyframes / keypoint "
"tracks. 0 = off. 7-15 calms spins/jitter where upstream Smooth misses spikes."
),
),
]),
IO.DynamicCombo.Option("openpose", [
IO.Float.Input(
"marker_radius_m", default=0.010, min=0.005, max=0.1, step=0.001, advanced=True,
tooltip="Sphere radius in m.",
),
IO.Float.Input(
"stick_radius_m", default=0.008, min=0.002, max=0.05, step=0.001, advanced=True,
tooltip="Limb half-width in m. Auto-clamped to bone_length x 0.1.",
),
IO.Boolean.Input(
"include_hands", default=False,
tooltip=(
"Append 21+21 OpenPose hands (wrist + 5 fingers x 4 joints, "
"base→tip) sourced from pred_keypoints_3d."
),
),
IO.Float.Input(
"hand_marker_radius_m", default=0.005, min=0.001, max=0.1, step=0.001, advanced=True,
tooltip="Hand sphere radius in m.",
),
IO.Float.Input(
"hand_stick_radius_m", default=0.003, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand limb half-width in m.",
),
IO.DynamicCombo.Option("bvh", [
IO.Combo.Input(
"face_style",
options=["disabled", "full", "eyes_mouth"],
default="disabled",
tooltip=(
"Face-contour landmarks sampled from pred_vertices at fixed "
"head-mesh vertex IDs (needs canonical_colors on pose_data). "
"'full' = all ~30 points; 'eyes_mouth' = eyes + outer lips only."
),
),
IO.Float.Input(
"face_marker_radius_m", default=0.0, min=0.0, max=0.05, step=0.0005, advanced=True,
tooltip="Face dot radius. 0 = auto = 0.3 x marker_radius_m.",
),
]),
IO.DynamicCombo.Option("scail", [
IO.Float.Input(
"stick_radius_m", default=0.022, min=0.002, max=0.1, step=0.001, advanced=True,
tooltip=(
"Cylinder radius in m. Bones are open cylinders at constant "
"radius; joint spheres (auto-sized to match) cap the open ends. "
"SCAIL reference = 0.0215 m."
),
),
IO.Float.Input(
"marker_radius_m", default=0.0, min=0.0, max=0.1, step=0.001, advanced=True,
tooltip="Joint sphere radius. 0 = auto = stick_radius_m (flush cap).",
),
IO.Float.Input(
"material_roughness", default=0.3, min=0.0, max=1.0, step=0.05, advanced=True,
tooltip="PBR roughness. SCAIL ref = 0.3. 1 = matte; 0 = chrome.",
),
IO.Boolean.Input(
"include_hands", default=False,
tooltip="Append 21+21 hand keypoints + capsule sticks per track.",
),
IO.Float.Input(
"hand_marker_radius_m", default=0.005, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand sphere radius in m.",
),
IO.Float.Input(
"hand_stick_radius_m", default=0.003, min=0.001, max=0.05, step=0.001, advanced=True,
tooltip="Hand cylinder radius in m.",
),
IO.Combo.Input(
"face_style",
options=["disabled", "full", "eyes_mouth"],
default="disabled",
tooltip=(
"Face-contour landmarks sampled from pred_vertices (needs "
"canonical_colors on pose_data). 'full' = all ~30 points; "
"'eyes_mouth' = eyes + outer lips only."
),
"units",
options=["cm", "m"],
default="cm",
tooltip="BVH OFFSET/position units. 'cm' is the mocap standard.",
),
]),
],
tooltip=(
"'body_mesh' = real Armature (127 bones, skinning, TRS keyframes, 72 face morphs; needs model). "
"'bones_only' = bone-shape primitives at each joint (preview armature). "
"'openpose' = OpenPose-18 3D skeleton from keypoints "
"'scail' = SCAIL 3D capsule rig (open cylinders capped flush by joint spheres)."
),
),
IO.Int.Input(
"bone_smooth_window",
default=0, min=0, max=51, step=2,
tooltip=(
"Gaussian smoothing window on per-bone rotation keyframes / keypoint "
"tracks. 0 = off. 7-15 calms spins/jitter where upstream Smooth misses spikes."
"Output format, both fed to Save 3D Model to write to disk. "
"'glb' = animated GLB (mesh / bones / openpose / scail). "
"'bvh' = BVH mocap clip (one skeleton; needs the model)."
),
),
IO.Float.Input(
@ -653,12 +675,32 @@ class BuildPoseGLB(IO.ComfyNode):
tooltip="-1 = all tracks; ≥0 = single track.",
),
],
outputs=[IO.File3DGLB.Output("model_3d")],
outputs=[IO.File3DAny.Output("model_3d")],
)
@classmethod
def execute(cls, pose_data, mesh_style, sam3d_body_model=None, bone_smooth_window=0, fps=24.0, camera_translation="off", track_index=-1) -> IO.NodeOutput:
mesh_style = mesh_style or {"mesh_style": "body_mesh"}
def execute(cls, pose_data, format, sam3d_body_model=None, fps=24.0, camera_translation="off", track_index=-1) -> IO.NodeOutput:
format = format or {"format": "glb"}
fmt = format.get("format", "glb")
if fmt == "bvh":
if sam3d_body_model is None:
raise ValueError("Create 3D Animation: 'bvh' format needs the `sam3d_body_model` input.")
# BVH carries one skeleton; -1 (all tracks) collapses to the first.
ti = int(track_index)
if ti < 0:
ti = 0
bvh_bytes = build_bvh(
pose_data, sam3d_body_model,
fps=float(fps),
camera_translation=str(camera_translation),
track_index=ti,
units=str(format.get("units", "cm")),
)
return IO.NodeOutput(Types.File3D(BytesIO(bvh_bytes), file_format="bvh"))
mesh_style = format.get("mesh_style") or {"mesh_style": "body_mesh"}
bone_smooth_window = int(format.get("bone_smooth_window", 0))
mode_key = mesh_style["mesh_style"]
# `shader` is nested in body_mesh; absent for bones_only.
shader_dict = mesh_style.get("shader") or {}
@ -678,7 +720,7 @@ class BuildPoseGLB(IO.ComfyNode):
has_external_rig = isinstance(pose_data, dict) and ("_skeleton_override" in pose_data)
if sam3d_body_model is None and not has_external_rig:
raise ValueError(
f"BuildPoseGLB: '{mode_key}' mode needs the `sam3d_body_model` input OR a "
f"BuildPoseFile: '{mode_key}' mode needs the `sam3d_body_model` input OR a "
"`_skeleton_override` dict in pose_data. Connect the SAM3DBody model "
"or feed pose_data from a node that supplies the override (e.g. KimodoSample)."
)
@ -748,78 +790,10 @@ class BuildPoseGLB(IO.ComfyNode):
return IO.NodeOutput(Types.File3D(BytesIO(glb_bytes), file_format="glb"))
class SavePoseBVH(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SavePoseBVH",
description="Save pose data as BVH mocap file",
display_name="Save Pose BVH",
category="3d",
is_output_node=True,
inputs=[
IO.MultiType.Input(
"pose_data", types=[MHRPoseData, KimodoPoseData],
tooltip=(
"MHR pose data from SAM3DBody_Predict, or external-rig "
"pose data from Kimodo."
),
),
SAM3DBodyModel.Input("sam3d_body_model"),
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
IO.Float.Input(
"fps", default=24.0, min=1.0, max=240.0, step=1.0,
tooltip="Animation frame rate (BVH `Frame Time`).",
),
camera_translation_input(),
IO.Combo.Input(
"units",
options=["cm", "m"],
default="cm",
tooltip="BVH OFFSET/position units. 'cm' is the mocap standard.",
),
IO.Int.Input(
"track_index", default=0, min=0, max=15,
tooltip="Track to export. BVH carries one skeleton; export multi-person clips one at a time.",
),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
outputs=[],
)
@classmethod
def execute(cls, pose_data, sam3d_body_model, filename_prefix="3d/ComfyUI",
fps=24.0, camera_translation="off", units="cm",
track_index=0) -> IO.NodeOutput:
bvh_bytes = build_bvh(
pose_data, sam3d_body_model,
fps=float(fps),
camera_translation=str(camera_translation),
track_index=int(track_index),
units=str(units),
)
full_output_folder, filename, counter, subfolder, _ = \
folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(),
)
f = f"{filename}_{counter:05}_.bvh"
out_path = os.path.join(full_output_folder, f)
with open(out_path, "wb") as fh:
fh.write(bvh_bytes)
return IO.NodeOutput(ui={"3d": [{
"filename": f,
"subfolder": subfolder,
"type": "output",
}]})
class Save3DExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [SaveGLB, BuildPoseGLB, SavePoseBVH]
return [SaveGLB, BuildPoseFile]
async def comfy_entrypoint() -> Save3DExtension: