| import os |
|
|
| from typing_extensions import override |
|
|
| from comfy_api.latest import IO, ComfyExtension, Input |
| from comfy_api_nodes.apis.meshy import ( |
| InputShouldRemesh, |
| InputShouldTexture, |
| MeshyAnimationRequest, |
| MeshyAnimationResult, |
| MeshyImageToModelRequest, |
| MeshyModelResult, |
| MeshyMultiImageToModelRequest, |
| MeshyRefineTask, |
| MeshyRiggedResult, |
| MeshyRiggingRequest, |
| MeshyTaskResponse, |
| MeshyTextToModelRequest, |
| MeshyTextureRequest, |
| ) |
| from comfy_api_nodes.util import ( |
| ApiEndpoint, |
| download_url_to_bytesio, |
| poll_op, |
| sync_op, |
| upload_images_to_comfyapi, |
| validate_string, |
| ) |
| from folder_paths import get_output_directory |
|
|
|
|
| class MeshyTextToModelNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyTextToModelNode", |
| display_name="Meshy: Text to Model", |
| category="api node/3d/Meshy", |
| inputs=[ |
| IO.Combo.Input("model", options=["latest"]), |
| IO.String.Input("prompt", multiline=True, default=""), |
| IO.Combo.Input("style", options=["realistic", "sculpture"]), |
| IO.DynamicCombo.Input( |
| "should_remesh", |
| options=[ |
| IO.DynamicCombo.Option( |
| "true", |
| [ |
| IO.Combo.Input("topology", options=["triangle", "quad"]), |
| IO.Int.Input( |
| "target_polycount", |
| default=300000, |
| min=100, |
| max=300000, |
| display_mode=IO.NumberDisplay.number, |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option("false", []), |
| ], |
| tooltip="When set to false, returns an unprocessed triangular mesh.", |
| ), |
| IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]), |
| IO.Combo.Input( |
| "pose_mode", |
| options=["", "A-pose", "T-pose"], |
| tooltip="Specify the pose mode for the generated model.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=2147483647, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Seed controls whether the node should re-run; " |
| "results are non-deterministic regardless of seed.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.8}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: str, |
| prompt: str, |
| style: str, |
| should_remesh: InputShouldRemesh, |
| symmetry_mode: str, |
| pose_mode: str, |
| seed: int, |
| ) -> IO.NodeOutput: |
| validate_string(prompt, field_name="prompt", min_length=1, max_length=600) |
| response = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyTextToModelRequest( |
| prompt=prompt, |
| art_style=style, |
| ai_model=model, |
| topology=should_remesh.get("topology", None), |
| target_polycount=should_remesh.get("target_polycount", None), |
| should_remesh=should_remesh["should_remesh"] == "true", |
| symmetry_mode=symmetry_mode, |
| pose_mode=pose_mode.lower(), |
| seed=seed, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"), |
| response_model=MeshyModelResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyRefineNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyRefineNode", |
| display_name="Meshy: Refine Draft Model", |
| category="api node/3d/Meshy", |
| description="Refine a previously created draft model.", |
| inputs=[ |
| IO.Combo.Input("model", options=["latest"]), |
| IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), |
| IO.Boolean.Input( |
| "enable_pbr", |
| default=False, |
| tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color. " |
| "Note: this should be set to false when using Sculpture style, " |
| "as Sculpture style generates its own set of PBR maps.", |
| ), |
| IO.String.Input( |
| "texture_prompt", |
| default="", |
| multiline=True, |
| tooltip="Provide a text prompt to guide the texturing process. " |
| "Maximum 600 characters. Cannot be used at the same time as 'texture_image'.", |
| ), |
| IO.Image.Input( |
| "texture_image", |
| tooltip="Only one of 'texture_image' or 'texture_prompt' may be used at the same time.", |
| optional=True, |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.4}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: str, |
| meshy_task_id: str, |
| enable_pbr: bool, |
| texture_prompt: str, |
| texture_image: Input.Image | None = None, |
| ) -> IO.NodeOutput: |
| if texture_prompt and texture_image is not None: |
| raise ValueError("texture_prompt and texture_image cannot be used at the same time") |
| texture_image_url = None |
| if texture_prompt: |
| validate_string(texture_prompt, field_name="texture_prompt", max_length=600) |
| if texture_image is not None: |
| texture_image_url = (await upload_images_to_comfyapi(cls, texture_image, wait_label="Uploading texture"))[0] |
| response = await sync_op( |
| cls, |
| endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyRefineTask( |
| preview_task_id=meshy_task_id, |
| enable_pbr=enable_pbr, |
| texture_prompt=texture_prompt if texture_prompt else None, |
| texture_image_url=texture_image_url, |
| ai_model=model, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"), |
| response_model=MeshyModelResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyImageToModelNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyImageToModelNode", |
| display_name="Meshy: Image to Model", |
| category="api node/3d/Meshy", |
| inputs=[ |
| IO.Combo.Input("model", options=["latest"]), |
| IO.Image.Input("image"), |
| IO.DynamicCombo.Input( |
| "should_remesh", |
| options=[ |
| IO.DynamicCombo.Option( |
| "true", |
| [ |
| IO.Combo.Input("topology", options=["triangle", "quad"]), |
| IO.Int.Input( |
| "target_polycount", |
| default=300000, |
| min=100, |
| max=300000, |
| display_mode=IO.NumberDisplay.number, |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option("false", []), |
| ], |
| tooltip="When set to false, returns an unprocessed triangular mesh.", |
| ), |
| IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]), |
| IO.DynamicCombo.Input( |
| "should_texture", |
| options=[ |
| IO.DynamicCombo.Option( |
| "true", |
| [ |
| IO.Boolean.Input( |
| "enable_pbr", |
| default=False, |
| tooltip="Generate PBR Maps (metallic, roughness, normal) " |
| "in addition to the base color.", |
| ), |
| IO.String.Input( |
| "texture_prompt", |
| default="", |
| multiline=True, |
| tooltip="Provide a text prompt to guide the texturing process. " |
| "Maximum 600 characters. Cannot be used at the same time as 'texture_image'.", |
| ), |
| IO.Image.Input( |
| "texture_image", |
| tooltip="Only one of 'texture_image' or 'texture_prompt' " |
| "may be used at the same time.", |
| optional=True, |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option("false", []), |
| ], |
| tooltip="Determines whether textures are generated. " |
| "Setting it to false skips the texture phase and returns a mesh without textures.", |
| ), |
| IO.Combo.Input( |
| "pose_mode", |
| options=["", "A-pose", "T-pose"], |
| tooltip="Specify the pose mode for the generated model.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=2147483647, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Seed controls whether the node should re-run; " |
| "results are non-deterministic regardless of seed.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]), |
| expr=""" |
| ( |
| $prices := {"true": 1.2, "false": 0.8}; |
| {"type":"usd","usd": $lookup($prices, widgets.should_texture)} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: str, |
| image: Input.Image, |
| should_remesh: InputShouldRemesh, |
| symmetry_mode: str, |
| should_texture: InputShouldTexture, |
| pose_mode: str, |
| seed: int, |
| ) -> IO.NodeOutput: |
| texture = should_texture["should_texture"] == "true" |
| texture_image_url = texture_prompt = None |
| if texture: |
| if should_texture["texture_prompt"] and should_texture["texture_image"] is not None: |
| raise ValueError("texture_prompt and texture_image cannot be used at the same time") |
| if should_texture["texture_prompt"]: |
| validate_string(should_texture["texture_prompt"], field_name="texture_prompt", max_length=600) |
| texture_prompt = should_texture["texture_prompt"] |
| if should_texture["texture_image"] is not None: |
| texture_image_url = ( |
| await upload_images_to_comfyapi( |
| cls, should_texture["texture_image"], wait_label="Uploading texture" |
| ) |
| )[0] |
| response = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/meshy/openapi/v1/image-to-3d", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyImageToModelRequest( |
| image_url=(await upload_images_to_comfyapi(cls, image, wait_label="Uploading base image"))[0], |
| ai_model=model, |
| topology=should_remesh.get("topology", None), |
| target_polycount=should_remesh.get("target_polycount", None), |
| symmetry_mode=symmetry_mode, |
| should_remesh=should_remesh["should_remesh"] == "true", |
| should_texture=texture, |
| enable_pbr=should_texture.get("enable_pbr", None), |
| pose_mode=pose_mode.lower(), |
| texture_prompt=texture_prompt, |
| texture_image_url=texture_image_url, |
| seed=seed, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v1/image-to-3d/{response.result}"), |
| response_model=MeshyModelResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyMultiImageToModelNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyMultiImageToModelNode", |
| display_name="Meshy: Multi-Image to Model", |
| category="api node/3d/Meshy", |
| inputs=[ |
| IO.Combo.Input("model", options=["latest"]), |
| IO.Autogrow.Input( |
| "images", |
| template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=2, max=4), |
| ), |
| IO.DynamicCombo.Input( |
| "should_remesh", |
| options=[ |
| IO.DynamicCombo.Option( |
| "true", |
| [ |
| IO.Combo.Input("topology", options=["triangle", "quad"]), |
| IO.Int.Input( |
| "target_polycount", |
| default=300000, |
| min=100, |
| max=300000, |
| display_mode=IO.NumberDisplay.number, |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option("false", []), |
| ], |
| tooltip="When set to false, returns an unprocessed triangular mesh.", |
| ), |
| IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]), |
| IO.DynamicCombo.Input( |
| "should_texture", |
| options=[ |
| IO.DynamicCombo.Option( |
| "true", |
| [ |
| IO.Boolean.Input( |
| "enable_pbr", |
| default=False, |
| tooltip="Generate PBR Maps (metallic, roughness, normal) " |
| "in addition to the base color.", |
| ), |
| IO.String.Input( |
| "texture_prompt", |
| default="", |
| multiline=True, |
| tooltip="Provide a text prompt to guide the texturing process. " |
| "Maximum 600 characters. Cannot be used at the same time as 'texture_image'.", |
| ), |
| IO.Image.Input( |
| "texture_image", |
| tooltip="Only one of 'texture_image' or 'texture_prompt' " |
| "may be used at the same time.", |
| optional=True, |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option("false", []), |
| ], |
| tooltip="Determines whether textures are generated. " |
| "Setting it to false skips the texture phase and returns a mesh without textures.", |
| ), |
| IO.Combo.Input( |
| "pose_mode", |
| options=["", "A-pose", "T-pose"], |
| tooltip="Specify the pose mode for the generated model.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=2147483647, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Seed controls whether the node should re-run; " |
| "results are non-deterministic regardless of seed.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]), |
| expr=""" |
| ( |
| $prices := {"true": 0.6, "false": 0.2}; |
| {"type":"usd","usd": $lookup($prices, widgets.should_texture)} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: str, |
| images: IO.Autogrow.Type, |
| should_remesh: InputShouldRemesh, |
| symmetry_mode: str, |
| should_texture: InputShouldTexture, |
| pose_mode: str, |
| seed: int, |
| ) -> IO.NodeOutput: |
| texture = should_texture["should_texture"] == "true" |
| texture_image_url = texture_prompt = None |
| if texture: |
| if should_texture["texture_prompt"] and should_texture["texture_image"] is not None: |
| raise ValueError("texture_prompt and texture_image cannot be used at the same time") |
| if should_texture["texture_prompt"]: |
| validate_string(should_texture["texture_prompt"], field_name="texture_prompt", max_length=600) |
| texture_prompt = should_texture["texture_prompt"] |
| if should_texture["texture_image"] is not None: |
| texture_image_url = ( |
| await upload_images_to_comfyapi( |
| cls, should_texture["texture_image"], wait_label="Uploading texture" |
| ) |
| )[0] |
| response = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/meshy/openapi/v1/multi-image-to-3d", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyMultiImageToModelRequest( |
| image_urls=await upload_images_to_comfyapi( |
| cls, list(images.values()), wait_label="Uploading base images" |
| ), |
| ai_model=model, |
| topology=should_remesh.get("topology", None), |
| target_polycount=should_remesh.get("target_polycount", None), |
| symmetry_mode=symmetry_mode, |
| should_remesh=should_remesh["should_remesh"] == "true", |
| should_texture=texture, |
| enable_pbr=should_texture.get("enable_pbr", None), |
| pose_mode=pose_mode.lower(), |
| texture_prompt=texture_prompt, |
| texture_image_url=texture_image_url, |
| seed=seed, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v1/multi-image-to-3d/{response.result}"), |
| response_model=MeshyModelResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyRigModelNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyRigModelNode", |
| display_name="Meshy: Rig Model", |
| category="api node/3d/Meshy", |
| description="Provides a rigged character in standard formats. " |
| "Auto-rigging is currently not suitable for untextured meshes, non-humanoid assets, " |
| "or humanoid assets with unclear limb and body structure.", |
| inputs=[ |
| IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), |
| IO.Float.Input( |
| "height_meters", |
| min=0.1, |
| max=15.0, |
| default=1.7, |
| tooltip="The approximate height of the character model in meters. " |
| "This aids in scaling and rigging accuracy.", |
| ), |
| IO.Image.Input( |
| "texture_image", |
| tooltip="The model's UV-unwrapped base color texture image.", |
| optional=True, |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MESHY_RIGGED_TASK_ID").Output(display_name="rig_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.2}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| meshy_task_id: str, |
| height_meters: float, |
| texture_image: Input.Image | None = None, |
| ) -> IO.NodeOutput: |
| texture_image_url = None |
| if texture_image is not None: |
| texture_image_url = (await upload_images_to_comfyapi(cls, texture_image, wait_label="Uploading texture"))[0] |
| response = await sync_op( |
| cls, |
| endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/rigging", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyRiggingRequest( |
| input_task_id=meshy_task_id, |
| height_meters=height_meters, |
| texture_image_url=texture_image_url, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v1/rigging/{response.result}"), |
| response_model=MeshyRiggedResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio( |
| result.result.rigged_character_glb_url, os.path.join(get_output_directory(), model_file) |
| ) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyAnimateModelNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyAnimateModelNode", |
| display_name="Meshy: Animate Model", |
| category="api node/3d/Meshy", |
| description="Apply a specific animation action to a previously rigged character.", |
| inputs=[ |
| IO.Custom("MESHY_RIGGED_TASK_ID").Input("rig_task_id"), |
| IO.Int.Input( |
| "action_id", |
| default=0, |
| min=0, |
| max=696, |
| tooltip="Visit https://docs.meshy.ai/en/api/animation-library for a list of available values.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.12}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| rig_task_id: str, |
| action_id: int, |
| ) -> IO.NodeOutput: |
| response = await sync_op( |
| cls, |
| endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/animations", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyAnimationRequest( |
| rig_task_id=rig_task_id, |
| action_id=action_id, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v1/animations/{response.result}"), |
| response_model=MeshyAnimationResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.result.animation_glb_url, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyTextureNode(IO.ComfyNode): |
|
|
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MeshyTextureNode", |
| display_name="Meshy: Texture Model", |
| category="api node/3d/Meshy", |
| inputs=[ |
| IO.Combo.Input("model", options=["latest"]), |
| IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), |
| IO.Boolean.Input( |
| "enable_original_uv", |
| default=True, |
| tooltip="Use the original UV of the model instead of generating new UVs. " |
| "When enabled, Meshy preserves existing textures from the uploaded model. " |
| "If the model has no original UV, the quality of the output might not be as good.", |
| ), |
| IO.Boolean.Input("pbr", default=False), |
| IO.String.Input( |
| "text_style_prompt", |
| default="", |
| multiline=True, |
| tooltip="Describe your desired texture style of the object using text. Maximum 600 characters." |
| "Maximum 600 characters. Cannot be used at the same time as 'image_style'.", |
| ), |
| IO.Image.Input( |
| "image_style", |
| optional=True, |
| tooltip="A 2d image to guide the texturing process. " |
| "Can not be used at the same time with 'text_style_prompt'.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output(display_name="model_file"), |
| IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| is_output_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.4}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: str, |
| meshy_task_id: str, |
| enable_original_uv: bool, |
| pbr: bool, |
| text_style_prompt: str, |
| image_style: Input.Image | None = None, |
| ) -> IO.NodeOutput: |
| if text_style_prompt and image_style is not None: |
| raise ValueError("text_style_prompt and image_style cannot be used at the same time") |
| if not text_style_prompt and image_style is None: |
| raise ValueError("Either text_style_prompt or image_style is required") |
| image_style_url = None |
| if image_style is not None: |
| image_style_url = (await upload_images_to_comfyapi(cls, image_style, wait_label="Uploading style"))[0] |
| response = await sync_op( |
| cls, |
| endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/retexture", method="POST"), |
| response_model=MeshyTaskResponse, |
| data=MeshyTextureRequest( |
| input_task_id=meshy_task_id, |
| ai_model=model, |
| enable_original_uv=enable_original_uv, |
| enable_pbr=pbr, |
| text_style_prompt=text_style_prompt if text_style_prompt else None, |
| image_style_url=image_style_url, |
| ), |
| ) |
| result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{response.result}"), |
| response_model=MeshyModelResult, |
| status_extractor=lambda r: r.status, |
| progress_extractor=lambda r: r.progress, |
| ) |
| model_file = f"meshy_model_{response.result}.glb" |
| await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) |
| return IO.NodeOutput(model_file, response.result) |
|
|
|
|
| class MeshyExtension(ComfyExtension): |
| @override |
| async def get_node_list(self) -> list[type[IO.ComfyNode]]: |
| return [ |
| MeshyTextToModelNode, |
| MeshyRefineNode, |
| MeshyImageToModelNode, |
| MeshyMultiImageToModelNode, |
| MeshyRigModelNode, |
| MeshyAnimateModelNode, |
| MeshyTextureNode, |
| ] |
|
|
|
|
| async def comfy_entrypoint() -> MeshyExtension: |
| return MeshyExtension() |
|
|