diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 012fae3ac..66f6502ca 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -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", diff --git a/comfy_extras/nodes_sam3d_body.py b/comfy_extras/nodes_sam3d_body.py index b56241aae..0c2b03565 100644 --- a/comfy_extras/nodes_sam3d_body.py +++ b/comfy_extras/nodes_sam3d_body.py @@ -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: diff --git a/comfy_extras/nodes_save_3d.py b/comfy_extras/nodes_save_3d.py index bb4e74fb2..05454242b 100644 --- a/comfy_extras/nodes_save_3d.py +++ b/comfy_extras/nodes_save_3d.py @@ -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: