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 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") @comfytype(io_type="FILE_3D_OBJ")
class File3DOBJ(ComfyTypeIO): class File3DOBJ(ComfyTypeIO):
"""OBJ format 3D file - simple geometry format.""" """OBJ format 3D file - simple geometry format."""
@ -2349,6 +2355,7 @@ __all__ = [
"File3DGLB", "File3DGLB",
"File3DGLTF", "File3DGLTF",
"File3DFBX", "File3DFBX",
"File3DBVH",
"File3DOBJ", "File3DOBJ",
"File3DSTL", "File3DSTL",
"File3DUSDZ", "File3DUSDZ",

View File

@ -33,12 +33,7 @@ from comfy_extras.sam3d_body.utils import image_to_uint8
SAM3TrackData = io.Custom("SAM3_TRACK_DATA") SAM3TrackData = io.Custom("SAM3_TRACK_DATA")
# MHRPoseData = SAM3DBody_Predict's native output (carries mhr_model_params, 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).
# 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")
SAM3DBodyModel = io.Custom("SAM3D_BODY_MODEL") SAM3DBodyModel = io.Custom("SAM3D_BODY_MODEL")
# Loader # Loader
@ -151,7 +146,7 @@ class SAM3DBody_Predict(io.ComfyNode):
), ),
), ),
io.Int.Input( io.Int.Input(
"chunk_size", #TODO: automate? "batch_size", #TODO: automate?
default=64, min=1, max=512, step=1, advanced=True, default=64, min=1, max=512, step=1, advanced=True,
tooltip=( tooltip=(
"Max frames to process as a batch. Larger values utilize more VRAM for faster inference." "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 @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) comfy.model_management.load_model_gpu(sam3d_body_model)
inner: SAM3DBody = sam3d_body_model.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. # Precedence: SAM3 track (masks + boxes) > detector boxes > full-frame fallback.
per_frame_bboxes, per_frame_masks = (None, None) per_frame_bboxes, per_frame_masks = (None, None)
if sam3_track_data is not None: if track_data is not None:
per_frame_bboxes, per_frame_masks = inputs_from_sam3_track(sam3_track_data, B, H, W) per_frame_bboxes, per_frame_masks = inputs_from_sam3_track(track_data, B, H, W)
if per_frame_bboxes is None and bboxes: if per_frame_bboxes is None and bboxes:
per_frame_bboxes = _per_frame_bboxes_from_detections(bboxes, B) per_frame_bboxes = _per_frame_bboxes_from_detections(bboxes, B)
per_frame_masks = None per_frame_masks = None
@ -209,7 +204,7 @@ class SAM3DBody_Predict(io.ComfyNode):
image_size, inference_type, image_size, inference_type,
cam_int=cam_int, cam_int=cam_int,
pbar=pbar, pbar=pbar,
crops_per_chunk=int(chunk_size), crops_per_chunk=int(batch_size),
) )
else: else:
# Mixed K per frame — call the batched path once per frame. # Mixed K per frame — call the batched path once per frame.
@ -459,7 +454,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
io.Combo.Input( io.Combo.Input(
"method", "method",
options=["gaussian", "savgol"], options=["gaussian", "savgol"],
default="gaussian", advanced=True, default="savgol", advanced=True,
tooltip=( tooltip=(
"gaussian: symmetric weighted average, best general-purpose smoother./n" "gaussian: symmetric weighted average, best general-purpose smoother./n"
"savgol: sliding polynomial fit, preserves sharp peaks." "savgol: sliding polynomial fit, preserves sharp peaks."
@ -471,7 +466,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
tooltip="Temporal window in frames (odd values).", tooltip="Temporal window in frames (odd values).",
), ),
io.Float.Input( io.Float.Input(
"rotation_threshold_deg", "rotation_threshold_degrees",
default=30.0, min=0.0, max=90.0, step=1.0, advanced=True, default=30.0, min=0.0, max=90.0, step=1.0, advanced=True,
tooltip=( tooltip=(
"Disables smoothing for this root rotation rate (degree/frame) to preserve fast spins. " "Disables smoothing for this root rotation rate (degree/frame) to preserve fast spins. "
@ -484,7 +479,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
) )
@classmethod @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: if strength <= 0.0 or window <= 1:
return io.NodeOutput(mhr_pose_data) return io.NodeOutput(mhr_pose_data)
@ -514,7 +509,7 @@ class SAM3DBody_Smooth(io.ComfyNode):
smoothed = [list(f) for f in frames] smoothed = [list(f) for f in frames]
base_blend = float(strength) 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): for pid in range(max_p):
valid = np.array([pid < len(f) for f in frames], dtype=bool) 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, "camera_info", optional=True,
tooltip=( tooltip=(
"Free 6DOF camera override. When wired, the pose is re-projected through this camera " "Free 6DOF camera override. When wired, the pose is re-projected through this camera "
"(position/target/zoom) instead of the predicted one. " "(position/target/zoom/rotation/FoV) 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."
), ),
), ),
io.DynamicCombo.Input( io.DynamicCombo.Input(
@ -893,7 +882,7 @@ class SAM3DBody_Render(io.ComfyNode):
@classmethod @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"} render_style = render_style or {"render_style": "mesh"}
mode_key = render_style.get("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) px_scale = min(new_W / native_W, new_H / native_H)
if camera_info is not None: 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"]) B = len(mhr_pose_data["frames"])
if B == 0: if B == 0:

View File

@ -342,6 +342,7 @@ class SaveGLB(IO.ComfyNode):
IO.File3DGLTF, IO.File3DGLTF,
IO.File3DOBJ, IO.File3DOBJ,
IO.File3DFBX, IO.File3DFBX,
IO.File3DBVH,
IO.File3DSTL, IO.File3DSTL,
IO.File3DUSDZ, IO.File3DUSDZ,
IO.File3DPLY, IO.File3DPLY,
@ -428,7 +429,7 @@ def rainbow_tilt_inputs():
def camera_translation_input(): def camera_translation_input():
"""Shared camera_translation combo (BuildPoseGLB + SavePoseBVH).""" """Shared camera_translation combo (Create 3D Animation glb + bvh paths)."""
return IO.Combo.Input( return IO.Combo.Input(
"camera_translation", "camera_translation",
options=["off", "centered", "absolute"], options=["off", "centered", "absolute"],
@ -442,15 +443,16 @@ def camera_translation_input():
) )
class BuildPoseGLB(IO.ComfyNode): class BuildPoseFile(IO.ComfyNode):
"""Convert pose_data to an in-memory animated GLB""" """Build an animated GLB from pose data, or save it as a BVH mocap file."""
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="BuildPoseGLB", node_id="BuildPoseFile",
display_name="Build Pose GLB", display_name="Create 3D Animation File",
description="Convert pose data to an animated GLB", 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", category="3d",
inputs=[ inputs=[
IO.MultiType.Input( IO.MultiType.Input(
@ -459,188 +461,208 @@ class BuildPoseGLB(IO.ComfyNode):
), ),
SAM3DBodyModel.Input("sam3d_body_model", optional=True), SAM3DBodyModel.Input("sam3d_body_model", optional=True),
IO.DynamicCombo.Input( IO.DynamicCombo.Input(
"mesh_style", "format",
options=[ options=[
IO.DynamicCombo.Option("body_mesh", [ IO.DynamicCombo.Option("glb", [
IO.DynamicCombo.Input( IO.DynamicCombo.Input(
"bone_vis", "mesh_style",
options=[ options=[
IO.DynamicCombo.Option("off", []), IO.DynamicCombo.Option("body_mesh", [
IO.DynamicCombo.Option("octahedrons", [ IO.DynamicCombo.Input(
IO.Float.Input( "bone_vis",
"bone_vis_radius_m", options=[
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True, IO.DynamicCombo.Option("off", []),
tooltip="Radius in m (sphere radius / octahedron half-width).", 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( IO.DynamicCombo.Input(
"bone_vis_color", "shader",
options=["white", "rainbow_y"], options=[
default="rainbow_y", 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=( tooltip=(
"Per-bone vertex colors (unlit material). " "Bake per-vertex colors matching the Render node's shaders "
"'white' = none, 'rainbow_y' = head→toe jet." "(COLOR_0 + KHR_materials_unlit). 'default' = no colors."
), ),
), ),
]), ]),
], IO.DynamicCombo.Option("bones_only", [
tooltip=("Bone vis shape, rigidly skinned to each joint. "), IO.DynamicCombo.Input(
), "bone_vis",
IO.DynamicCombo.Input( options=[
"shader", IO.DynamicCombo.Option("octahedrons", [
options=[ IO.Float.Input(
IO.DynamicCombo.Option("default", []), "bone_vis_radius_m",
IO.DynamicCombo.Option("rainbow", [ default=0.02, min=0.005, max=0.5, step=0.005, advanced=True,
*rainbow_tilt_inputs(), tooltip="Radius in m (sphere radius / octahedron half-width).",
IO.Float.Input( ),
"person_palette_falloff", IO.Combo.Input(
default=0.6, min=0.1, max=1.0, step=0.05, "bone_vis_color",
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.", 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", [ IO.DynamicCombo.Option("openpose", [
*rainbow_tilt_inputs(),
IO.Float.Input( IO.Float.Input(
"person_palette_falloff", "marker_radius_m", default=0.010, min=0.005, max=0.1, step=0.001, advanced=True,
default=0.6, min=0.1, max=1.0, step=0.05, tooltip="Sphere radius in m.",
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.",
), ),
]),
IO.DynamicCombo.Option("rainbow_face_semantic", [
*rainbow_tilt_inputs(),
IO.Float.Input( IO.Float.Input(
"person_palette_falloff", "stick_radius_m", default=0.008, min=0.002, max=0.05, step=0.001, advanced=True,
default=0.6, min=0.1, max=1.0, step=0.05, tooltip="Limb half-width in m. Auto-clamped to bone_length x 0.1.",
tooltip="Per-person desaturation: each track gets (1 - falloff^k) pastel mix.", ),
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( IO.Float.Input(
"bone_vis_radius_m", "hand_marker_radius_m", default=0.005, min=0.001, max=0.1, step=0.001, advanced=True,
default=0.02, min=0.005, max=0.5, step=0.005, advanced=True, tooltip="Hand sphere radius in m.",
tooltip="Radius in m (sphere radius / octahedron half-width).", ),
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( IO.Combo.Input(
"bone_vis_color", "face_style",
options=["white", "rainbow_y"], options=["disabled", "full", "eyes_mouth"],
default="rainbow_y", default="disabled",
tooltip=( tooltip=(
"Per-bone vertex colors (unlit material). " "Face-contour landmarks sampled from pred_vertices at fixed "
"'white' = none, 'rainbow_y' = head→toe jet." "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=( tooltip=(
"Bone vis shape, rigidly skinned to each joint. " "'body_mesh' = real Armature (127 bones, skinning, TRS keyframes, 72 face morphs; needs model). "
"'octahedrons' = Blender-style directional bones (joint → " "'bones_only' = bone-shape primitives at each joint (preview armature). "
"primary child)." "'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.DynamicCombo.Option("bvh", [
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.Combo.Input( IO.Combo.Input(
"face_style", "units",
options=["disabled", "full", "eyes_mouth"], options=["cm", "m"],
default="disabled", default="cm",
tooltip=( tooltip="BVH OFFSET/position units. 'cm' is the mocap standard.",
"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=( tooltip=(
"'body_mesh' = real Armature (127 bones, skinning, TRS keyframes, 72 face morphs; needs model). " "Output format, both fed to Save 3D Model to write to disk. "
"'bones_only' = bone-shape primitives at each joint (preview armature). " "'glb' = animated GLB (mesh / bones / openpose / scail). "
"'openpose' = OpenPose-18 3D skeleton from keypoints " "'bvh' = BVH mocap clip (one skeleton; needs the model)."
"'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.Float.Input( IO.Float.Input(
@ -653,12 +675,32 @@ class BuildPoseGLB(IO.ComfyNode):
tooltip="-1 = all tracks; ≥0 = single track.", tooltip="-1 = all tracks; ≥0 = single track.",
), ),
], ],
outputs=[IO.File3DGLB.Output("model_3d")], outputs=[IO.File3DAny.Output("model_3d")],
) )
@classmethod @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: def execute(cls, pose_data, format, sam3d_body_model=None, fps=24.0, camera_translation="off", track_index=-1) -> IO.NodeOutput:
mesh_style = mesh_style or {"mesh_style": "body_mesh"} 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"] mode_key = mesh_style["mesh_style"]
# `shader` is nested in body_mesh; absent for bones_only. # `shader` is nested in body_mesh; absent for bones_only.
shader_dict = mesh_style.get("shader") or {} 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) has_external_rig = isinstance(pose_data, dict) and ("_skeleton_override" in pose_data)
if sam3d_body_model is None and not has_external_rig: if sam3d_body_model is None and not has_external_rig:
raise ValueError( 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 " "`_skeleton_override` dict in pose_data. Connect the SAM3DBody model "
"or feed pose_data from a node that supplies the override (e.g. KimodoSample)." "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")) 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): class Save3DExtension(ComfyExtension):
@override @override
async def get_node_list(self) -> list[type[IO.ComfyNode]]: async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [SaveGLB, BuildPoseGLB, SavePoseBVH] return [SaveGLB, BuildPoseFile]
async def comfy_entrypoint() -> Save3DExtension: async def comfy_entrypoint() -> Save3DExtension: