From f58db0e32508351dbb3513bd36be6947d274ec8b Mon Sep 17 00:00:00 2001 From: raybobo <378677982@qq.com> Date: Tue, 22 Jul 2025 16:35:42 +0800 Subject: [PATCH] add basic meshy node --- comfy_api_nodes/apis/meshy_api.py | 87 +++++++ comfy_api_nodes/nodes_meshy.py | 363 ++++++++++++++++++++++++++++++ nodes.py | 1 + 3 files changed, 451 insertions(+) create mode 100644 comfy_api_nodes/apis/meshy_api.py create mode 100644 comfy_api_nodes/nodes_meshy.py diff --git a/comfy_api_nodes/apis/meshy_api.py b/comfy_api_nodes/apis/meshy_api.py new file mode 100644 index 000000000..e0d856461 --- /dev/null +++ b/comfy_api_nodes/apis/meshy_api.py @@ -0,0 +1,87 @@ +from enum import Enum +from pydantic import BaseModel, Field +from typing import Optional + + +class MeshyAIModel(str, Enum): + meshy_4 = "meshy-4" + meshy_5 = "meshy-5" + + +class MeshyTopology(str, Enum): + quad = "quad" + triangle = "triangle" + + +class MeshySymmetryMode(str, Enum): + off = "off" + auto = "auto" + on = "on" + + +class MeshyArtStyle(str, Enum): + realistic = "realistic" + sculpture = "sculpture" + + +class MeshyTaskStatus(str, Enum): + pending = "PENDING" + in_progress = "IN_PROGRESS" + completed = "SUCCEEDED" + failed = "FAILED" + canceled = "CANCELED" + + +class MeshyTask(BaseModel): + id: str = Field(..., description="The task ID") + status: MeshyTaskStatus = Field(..., description="The status of the task") + progress: int = Field(..., description="The progress of the task") + message: str = Field(..., description="The message of the task") + + +class MeshyTaskResponse(BaseModel): + code: int = Field(..., description="The response code") + data: MeshyTask = Field(..., description="The task data") + + +class MeshyTextToModelPreviewRequest(BaseModel): + prompt: str = Field( + ..., + description="The text prompt describing the model to generate", + max_length=1024, + ) + mode: str = "preview" + art_style: Optional[MeshyArtStyle] = None + seed: Optional[int] = None + ai_model: Optional[MeshyAIModel] = None + topology: Optional[MeshyTopology] = None + target_polycount: Optional[int] = None + should_remesh: Optional[bool] = None + symmetry_mode: Optional[MeshySymmetryMode] = None + should_simplify: Optional[bool] = None + + +class MeshyTaskResponse(BaseModel): + model_file: str = Field(..., description="The model file") + model_task_id: str = Field(..., description="The model task ID") + + +class MeshyTextToModelRefineRequest(BaseModel): + mode: str = "refine" + preview_task_id: str = Field(..., description="The preview task ID") + enable_pbr: bool = Field(..., description="Whether to enable PBR") + texture_prompt: str = Field(..., description="The texture prompt") + ai_model: Optional[MeshyAIModel] = None + + +class MeshyImageToModelRequest(BaseModel): + image_url: str = Field(..., description="The image URL") + ai_model: Optional[MeshyAIModel] = None + topology: Optional[MeshyTopology] = None + target_polycount: Optional[int] = None + symmetry_mode: Optional[MeshySymmetryMode] = None + should_remesh: Optional[bool] = None + should_texture: Optional[bool] = None + enable_pbr: Optional[bool] = None + texture_prompt: Optional[str] = None + moderation: Optional[bool] = None diff --git a/comfy_api_nodes/nodes_meshy.py b/comfy_api_nodes/nodes_meshy.py new file mode 100644 index 000000000..757a2dee1 --- /dev/null +++ b/comfy_api_nodes/nodes_meshy.py @@ -0,0 +1,363 @@ +import os +from folder_paths import get_output_directory +from comfy_api_nodes.mapper_utils import model_field_to_node_input +from comfy.comfy_types.node_typing import IO +from comfy_api_nodes.apis.meshy_api import ( + MeshyArtStyle, + MeshyAIModel, + MeshySymmetryMode, + MeshyTopology, + MeshyTextToModelPreviewRequest, + MeshyTaskResponse, + MeshyTaskResponse, + MeshyTaskStatus, + MeshyTextToModelRefineRequest, + MeshyImageToModelRequest, +) + +from comfy_api_nodes.apis.client import ( + ApiEndpoint, + HttpMethod, + SynchronousOperation, + PollingOperation, + EmptyRequest, +) +from comfy_api_nodes.apinode_utils import ( + upload_images_to_comfyapi, + download_url_to_bytesio, +) + + +def get_model_url_from_response(response: MeshyTaskResponse) -> str: + if response.data is not None: + return response.data.model_urls.glb + raise RuntimeError(f"Failed to get model url from response: {response}") + + +def poll_until_finished( + kwargs: dict[str, str], + response: MeshyTaskResponse, +) -> tuple[str, str]: + """Polls the Meshy API endpoint until the task reaches a terminal state, then returns the response.""" + if response.code != 0: + raise RuntimeError(f"Failed to generate mesh: {response.error}") + task_id = response.data.task_id + response_poll = PollingOperation( + poll_endpoint=ApiEndpoint( + path=f"/proxy/meshy/openapi/v2/task/{task_id}", + method=HttpMethod.GET, + request_model=EmptyRequest, + response_model=MeshyTaskResponse, + ), + completed_statuses=[MeshyTaskStatus.completed], + failed_statuses=[ + MeshyTaskStatus.failed, + MeshyTaskStatus.canceled, + ], + status_extractor=lambda x: x.data.status, + auth_kwargs=kwargs, + node_id=kwargs["unique_id"], + result_url_extractor=get_model_url_from_response, + progress_extractor=lambda x: x.data.progress, + ).execute() + if response_poll.data.status == MeshyTaskStatus.completed: + url = get_model_url_from_response(response_poll) + bytesio = download_url_to_bytesio(url) + # Save the downloaded model file + model_file = f"meshy_model_{task_id}.glb" + with open(os.path.join(get_output_directory(), model_file), "wb") as f: + f.write(bytesio.getvalue()) + return model_file, task_id + raise RuntimeError(f"Failed to generate mesh: {response_poll}") + + +class MeshyTextToModelPreviewNode: + """ + Generates 3D models preview based on a text prompt using Meshy's API. + """ + + AVERAGE_DURATION = 80 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ("STRING", {"multiline": True}), + }, + "optional": { + "art_style": model_field_to_node_input( + IO.COMBO, + MeshyTextToModelPreviewRequest, + "art_style", + enum_type=MeshyArtStyle, + default="realistic", + ), + "seed": ("INT", {"default": 42}), + "ai_model": model_field_to_node_input( + IO.COMBO, + MeshyTextToModelPreviewRequest, + "ai_model", + enum_type=MeshyAIModel, + ), + "topology": model_field_to_node_input( + IO.COMBO, + MeshyTextToModelPreviewRequest, + "topology", + enum_type=MeshyTopology, + ), + "target_polycount": ( + "INT", + {"min": 100, "max": 300000, "default": 30000}, + ), + "should_remesh": ("BOOLEAN", {"default": True}), + "symmetry_mode": model_field_to_node_input( + IO.COMBO, + MeshyTextToModelPreviewRequest, + "symmetry_mode", + enum_type=MeshySymmetryMode, + ), + "should_simplify": ("BOOLEAN", {"default": False}), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ( + "STRING", + "MODEL_TASK_ID", + ) + RETURN_NAMES = ("model_file", "model task_id") + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Meshy" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh( + self, + prompt, + art_style=None, + seed=None, + ai_model=None, + topology=None, + target_polycount=None, + should_remesh=None, + symmetry_mode=None, + should_simplify=None, + **kwargs, + ): + if not prompt: + raise RuntimeError("Prompt is required") + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/meshy/openapi/v2/text-to-3d", + method=HttpMethod.POST, + request_model=MeshyTextToModelPreviewRequest, + response_model=MeshyTaskResponse, + ), + request=MeshyTextToModelPreviewRequest( + mode="preview", + prompt=prompt, + art_style=art_style, + seed=seed, + ai_model=ai_model, + topology=topology, + target_polycount=target_polycount, + should_remesh=should_remesh, + symmetry_mode=symmetry_mode, + should_simplify=should_simplify, + ), + auth_kwargs=kwargs.get("auth_kwargs", {}), + ).execute() + return poll_until_finished(kwargs, response) + + +class MeshyTextToModelRefineNode: + """ + Refines a 3D model based on a text prompt using Meshy's API. + """ + + AVERAGE_DURATION = 80 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "preview_task_id": ("MODEL_TASK_ID",), + }, + "optional": { + "enable_pbr": ("BOOLEAN", {"default": True}), + "texture_prompt": ("STRING", {"multiline": True}), + "ai_model": model_field_to_node_input( + IO.COMBO, + MeshyTextToModelRefineRequest, + "ai_model", + enum_type=MeshyAIModel, + ), + "moderation": ("BOOLEAN", {"default": False}), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ( + "STRING", + "STRING", + ) + RETURN_NAMES = ("model_file", "preview task_id") + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Meshy" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh( + self, + preview_task_id, + enable_pbr=None, + texture_prompt=None, + ai_model=None, + moderation=None, + **kwargs, + ): + if not preview_task_id: + raise RuntimeError("Preview task ID is required") + + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/meshy/openapi/v2/text-to-3d", + method=HttpMethod.POST, + request_model=MeshyTextToModelRefineRequest, + response_model=MeshyTaskResponse, + ), + request=MeshyTextToModelRefineRequest( + mode="refine", + preview_task_id=preview_task_id, + enable_pbr=enable_pbr, + texture_prompt=texture_prompt, + ai_model=ai_model, + moderation=moderation, + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + + +class MeshyImageToModelNode: + """ + Generates 3D models synchronously based on a single image using Meshy's API. + """ + + AVERAGE_DURATION = 80 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "ai_model": model_field_to_node_input( + IO.COMBO, + MeshyImageToModelRequest, + "ai_model", + enum_type=MeshyAIModel, + ), + "topology": model_field_to_node_input( + IO.COMBO, + MeshyImageToModelRequest, + "topology", + enum_type=MeshyTopology, + ), + "target_polycount": ( + "INT", + {"min": 100, "max": 300000, "default": 30000}, + ), + "should_remesh": ("BOOLEAN", {"default": True}), + "symmetry_mode": model_field_to_node_input( + IO.COMBO, + MeshyImageToModelRequest, + "symmetry_mode", + enum_type=MeshySymmetryMode, + ), + "should_remesh": ("BOOLEAN", {"default": True}), + "should_texture": ("BOOLEAN", {"default": True}), + "enable_pbr": ("BOOLEAN", {"default": True}), + "texture_prompt": ("STRING", {"multiline": True}), + "moderation": ("BOOLEAN", {"default": False}), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ( + "STRING", + "STRING", + ) + RETURN_NAMES = ("model_file", "model task_id") + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Meshy" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh( + self, + image, + ai_model=None, + topology=None, + target_polycount=None, + should_remesh=None, + symmetry_mode=None, + should_texture=None, + enable_pbr=None, + texture_prompt=None, + moderation=None, + **kwargs, + ): + if not image: + raise RuntimeError("Image is required") + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/meshy/openapi/v2/image-to-3d", + method=HttpMethod.POST, + request_model=MeshyImageToModelRequest, + response_model=MeshyTaskResponse, + ), + request=MeshyImageToModelRequest( + mode="image", + image_url=image, + ai_model=ai_model, + topology=topology, + target_polycount=target_polycount, + should_remesh=should_remesh, + symmetry_mode=symmetry_mode, + should_texture=should_texture, + enable_pbr=enable_pbr, + texture_prompt=texture_prompt, + moderation=moderation, + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + + +# A dictionary that contains all nodes you want to export with their names +NODE_CLASS_MAPPINGS = { + "MeshyTextToModelPreviewNode": MeshyTextToModelPreviewNode, + "MeshyTextToModelRefineNode": MeshyTextToModelRefineNode, + "MeshyImageToModelNode": MeshyImageToModelNode, +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "MeshyTextToModelPreviewNode": "Meshy: Text to Model (Preview)", + "MeshyTextToModelRefineNode": "Meshy: Text to Model (Refine)", + "MeshyImageToModelNode": "Meshy: Image to Model", +} diff --git a/nodes.py b/nodes.py index 231d4d4de..f98635509 100644 --- a/nodes.py +++ b/nodes.py @@ -2311,6 +2311,7 @@ def init_builtin_api_nodes(): "nodes_runway.py", "nodes_tripo.py", "nodes_moonvalley.py", + "nodes_meshy.py", "nodes_rodin.py", "nodes_gemini.py", ]