add basic meshy node

This commit is contained in:
raybobo 2025-07-22 16:35:42 +08:00
parent 0aa1c58b04
commit f58db0e325
3 changed files with 451 additions and 0 deletions

View File

@ -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

View File

@ -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",
}

View File

@ -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",
]