diff --git a/comfy_api_nodes/apis/__init__.py b/comfy_api_nodes/apis/__init__.py index 517923915..7ec7e9423 100644 --- a/comfy_api_nodes/apis/__init__.py +++ b/comfy_api_nodes/apis/__init__.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: filtered-openapi.yaml -# timestamp: 2025-05-20T01:04:45+00:00 +# timestamp: 2025-05-19T21:38:55+00:00 from __future__ import annotations @@ -4095,7 +4095,6 @@ class RunwayTextToImageAspectRatioEnum(str, Enum): field_1808_768 = '1808:768' field_2112_912 = '2112:912' - class Model4(str, Enum): gen4_image = 'gen4_image' @@ -4270,9 +4269,9 @@ class TripoModelStyle(str, Enum): class TripoModelVersion(str, Enum): - v2_5_20250123 = 'v2.5-20250123' - v2_0_20240919 = 'v2.0-20240919' - v1_4_20240625 = 'v1.4-20240625' + V2_5 = 'v2.5-20250123' + V2_0 = 'v2.0-20240919' + V1_4 = 'v1.4-20240625' class TripoMultiviewMode(str, Enum): @@ -4597,7 +4596,8 @@ class WebSearchToolCall(BaseModel): ) -CreateModelResponseProperties = ModelResponseProperties +class CreateModelResponseProperties(ModelResponseProperties): + pass class GeminiInlineData(BaseModel): diff --git a/comfy_api_nodes/apis/client.py b/comfy_api_nodes/apis/client.py index a15dad1ae..be7231eae 100644 --- a/comfy_api_nodes/apis/client.py +++ b/comfy_api_nodes/apis/client.py @@ -142,7 +142,7 @@ class EmptyRequest(BaseModel): class UploadRequest(BaseModel): file_name: str = Field(..., description="Filename to upload") - content_type: str | None = Field( + content_type: Optional[str] = Field( None, description="Mime type of the file. For example: image/png, image/jpeg, video/mp4, etc.", ) diff --git a/comfy_api_nodes/apis/tripo_api.py b/comfy_api_nodes/apis/tripo_api.py new file mode 100644 index 000000000..bb479e4cd --- /dev/null +++ b/comfy_api_nodes/apis/tripo_api.py @@ -0,0 +1,272 @@ +from __future__ import annotations +from comfy_api_nodes.apis import ( + TripoModelVersion, + TripoTextureQuality, +) +from enum import Enum +from typing import Optional, List, Dict, Any, Union + +from pydantic import BaseModel, Field, RootModel + +class TripoStyle(str, Enum): + PERSON_TO_CARTOON = "person:person2cartoon" + ANIMAL_VENOM = "animal:venom" + OBJECT_CLAY = "object:clay" + OBJECT_STEAMPUNK = "object:steampunk" + OBJECT_CHRISTMAS = "object:christmas" + OBJECT_BARBIE = "object:barbie" + GOLD = "gold" + ANCIENT_BRONZE = "ancient_bronze" + NONE = "None" + +class TripoTaskType(str, Enum): + TEXT_TO_MODEL = "text_to_model" + IMAGE_TO_MODEL = "image_to_model" + MULTIVIEW_TO_MODEL = "multiview_to_model" + TEXTURE_MODEL = "texture_model" + REFINE_MODEL = "refine_model" + ANIMATE_PRERIGCHECK = "animate_prerigcheck" + ANIMATE_RIG = "animate_rig" + ANIMATE_RETARGET = "animate_retarget" + STYLIZE_MODEL = "stylize_model" + CONVERT_MODEL = "convert_model" + +class TripoTextureAlignment(str, Enum): + ORIGINAL_IMAGE = "original_image" + GEOMETRY = "geometry" + +class TripoOrientation(str, Enum): + ALIGN_IMAGE = "align_image" + DEFAULT = "default" + +class TripoOutFormat(str, Enum): + GLB = "glb" + FBX = "fbx" + +class TripoTopology(str, Enum): + BIP = "bip" + QUAD = "quad" + +class TripoSpec(str, Enum): + MIXAMO = "mixamo" + TRIPO = "tripo" + +class TripoAnimation(str, Enum): + IDLE = "preset:idle" + WALK = "preset:walk" + CLIMB = "preset:climb" + JUMP = "preset:jump" + RUN = "preset:run" + SLASH = "preset:slash" + SHOOT = "preset:shoot" + HURT = "preset:hurt" + FALL = "preset:fall" + TURN = "preset:turn" + +class TripoStylizeStyle(str, Enum): + LEGO = "lego" + VOXEL = "voxel" + VORONOI = "voronoi" + MINECRAFT = "minecraft" + +class TripoConvertFormat(str, Enum): + GLTF = "GLTF" + USDZ = "USDZ" + FBX = "FBX" + OBJ = "OBJ" + STL = "STL" + _3MF = "3MF" + +class TripoTextureFormat(str, Enum): + BMP = "BMP" + DPX = "DPX" + HDR = "HDR" + JPEG = "JPEG" + OPEN_EXR = "OPEN_EXR" + PNG = "PNG" + TARGA = "TARGA" + TIFF = "TIFF" + WEBP = "WEBP" + +class TripoTaskStatus(str, Enum): + QUEUED = "queued" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" + UNKNOWN = "unknown" + BANNED = "banned" + EXPIRED = "expired" + +class TripoFileTokenReference(BaseModel): + type: Optional[str] = Field(None, description='The type of the reference') + file_token: str + +class TripoUrlReference(BaseModel): + type: Optional[str] = Field(None, description='The type of the reference') + url: str + +class TripoObjectStorage(BaseModel): + bucket: str + key: str + +class TripoObjectReference(BaseModel): + type: str + object: TripoObjectStorage + +class TripoFileReference(RootModel): + root: Union[TripoFileTokenReference, TripoUrlReference, TripoObjectReference] + +class TripoGetStsTokenRequest(BaseModel): + format: str = Field(..., description='The format of the image') + +class TripoTextToModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.TEXT_TO_MODEL, description='Type of task') + prompt: str = Field(..., description='The text prompt describing the model to generate', max_length=1024) + negative_prompt: Optional[str] = Field(None, description='The negative text prompt', max_length=1024) + model_version: Optional[TripoModelVersion] = TripoModelVersion.V2_5 + face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to') + texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model') + pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model') + image_seed: Optional[int] = Field(None, description='The seed for the text') + model_seed: Optional[int] = Field(None, description='The seed for the model') + texture_seed: Optional[int] = Field(None, description='The seed for the texture') + texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard + style: Optional[TripoStyle] = None + auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model') + quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model') + +class TripoImageToModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.IMAGE_TO_MODEL, description='Type of task') + file: TripoFileReference = Field(..., description='The file reference to convert to a model') + model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation') + face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to') + texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model') + pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model') + model_seed: Optional[int] = Field(None, description='The seed for the model') + texture_seed: Optional[int] = Field(None, description='The seed for the texture') + texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard + texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method') + style: Optional[TripoStyle] = Field(None, description='The style to apply to the generated model') + auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model') + orientation: Optional[TripoOrientation] = TripoOrientation.DEFAULT + quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model') + +class TripoMultiviewToModelRequest(BaseModel): + type: TripoTaskType = TripoTaskType.MULTIVIEW_TO_MODEL + files: List[TripoFileReference] = Field(..., description='The file references to convert to a model') + model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation') + orthographic_projection: Optional[bool] = Field(False, description='Whether to use orthographic projection') + face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to') + texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model') + pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model') + model_seed: Optional[int] = Field(None, description='The seed for the model') + texture_seed: Optional[int] = Field(None, description='The seed for the texture') + texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard + texture_alignment: Optional[TripoTextureAlignment] = TripoTextureAlignment.ORIGINAL_IMAGE + auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model') + orientation: Optional[TripoOrientation] = Field(TripoOrientation.DEFAULT, description='The orientation for the model') + quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model') + +class TripoTextureModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.TEXTURE_MODEL, description='Type of task') + original_model_task_id: str = Field(..., description='The task ID of the original model') + texture: Optional[bool] = Field(True, description='Whether to apply texture to the model') + pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the model') + model_seed: Optional[int] = Field(None, description='The seed for the model') + texture_seed: Optional[int] = Field(None, description='The seed for the texture') + texture_quality: Optional[TripoTextureQuality] = Field(None, description='The quality of the texture') + texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method') + +class TripoRefineModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.REFINE_MODEL, description='Type of task') + draft_model_task_id: str = Field(..., description='The task ID of the draft model') + +class TripoAnimatePrerigcheckRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.ANIMATE_PRERIGCHECK, description='Type of task') + original_model_task_id: str = Field(..., description='The task ID of the original model') + +class TripoAnimateRigRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.ANIMATE_RIG, description='Type of task') + original_model_task_id: str = Field(..., description='The task ID of the original model') + out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format') + spec: Optional[TripoSpec] = Field(TripoSpec.TRIPO, description='The specification for rigging') + +class TripoAnimateRetargetRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.ANIMATE_RETARGET, description='Type of task') + original_model_task_id: str = Field(..., description='The task ID of the original model') + animation: TripoAnimation = Field(..., description='The animation to apply') + out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format') + bake_animation: Optional[bool] = Field(True, description='Whether to bake the animation') + +class TripoStylizeModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.STYLIZE_MODEL, description='Type of task') + style: TripoStylizeStyle = Field(..., description='The style to apply to the model') + original_model_task_id: str = Field(..., description='The task ID of the original model') + block_size: Optional[int] = Field(80, description='The block size for stylization') + +class TripoConvertModelRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.CONVERT_MODEL, description='Type of task') + format: TripoConvertFormat = Field(..., description='The format to convert to') + original_model_task_id: str = Field(..., description='The task ID of the original model') + quad: Optional[bool] = Field(False, description='Whether to apply quad to the model') + force_symmetry: Optional[bool] = Field(False, description='Whether to force symmetry') + face_limit: Optional[int] = Field(10000, description='The number of faces to limit the conversion to') + flatten_bottom: Optional[bool] = Field(False, description='Whether to flatten the bottom of the model') + flatten_bottom_threshold: Optional[float] = Field(0.01, description='The threshold for flattening the bottom') + texture_size: Optional[int] = Field(4096, description='The size of the texture') + texture_format: Optional[TripoTextureFormat] = Field(TripoTextureFormat.JPEG, description='The format of the texture') + pivot_to_center_bottom: Optional[bool] = Field(False, description='Whether to pivot to the center bottom') + +class TripoTaskRequest(RootModel): + root: Union[ + TripoTextToModelRequest, + TripoImageToModelRequest, + TripoMultiviewToModelRequest, + TripoTextureModelRequest, + TripoRefineModelRequest, + TripoAnimatePrerigcheckRequest, + TripoAnimateRigRequest, + TripoAnimateRetargetRequest, + TripoStylizeModelRequest, + TripoConvertModelRequest + ] + +class TripoTaskOutput(BaseModel): + model: Optional[str] = Field(None, description='URL to the model') + base_model: Optional[str] = Field(None, description='URL to the base model') + pbr_model: Optional[str] = Field(None, description='URL to the PBR model') + rendered_image: Optional[str] = Field(None, description='URL to the rendered image') + riggable: Optional[bool] = Field(None, description='Whether the model is riggable') + +class TripoTask(BaseModel): + task_id: str = Field(..., description='The task ID') + type: Optional[str] = Field(None, description='The type of task') + status: Optional[TripoTaskStatus] = Field(None, description='The status of the task') + input: Optional[Dict[str, Any]] = Field(None, description='The input parameters for the task') + output: Optional[TripoTaskOutput] = Field(None, description='The output of the task') + progress: Optional[int] = Field(None, description='The progress of the task', ge=0, le=100) + create_time: Optional[int] = Field(None, description='The creation time of the task') + running_left_time: Optional[int] = Field(None, description='The estimated time left for the task') + queue_position: Optional[int] = Field(None, description='The position in the queue') + +class TripoTaskResponse(BaseModel): + code: int = Field(0, description='The response code') + data: TripoTask = Field(..., description='The task data') + +class TripoGeneralResponse(BaseModel): + code: int = Field(0, description='The response code') + data: Dict[str, str] = Field(..., description='The task ID data') + +class TripoBalanceData(BaseModel): + balance: float = Field(..., description='The account balance') + frozen: float = Field(..., description='The frozen balance') + +class TripoBalanceResponse(BaseModel): + code: int = Field(0, description='The response code') + data: TripoBalanceData = Field(..., description='The balance data') + +class TripoErrorResponse(BaseModel): + code: int = Field(..., description='The error code') + message: str = Field(..., description='The error message') + suggestion: str = Field(..., description='The suggestion for fixing the error') diff --git a/comfy_api_nodes/nodes_tripo.py b/comfy_api_nodes/nodes_tripo.py new file mode 100644 index 000000000..bcef14867 --- /dev/null +++ b/comfy_api_nodes/nodes_tripo.py @@ -0,0 +1,567 @@ +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 import ( + TripoOrientation, + TripoModelVersion, +) +from comfy_api_nodes.apis.tripo_api import ( + TripoTaskType, + TripoStyle, + TripoFileReference, + TripoUrlReference, + TripoTaskResponse, + TripoTaskStatus, + TripoTextToModelRequest, + TripoImageToModelRequest, + TripoMultiviewToModelRequest, + TripoTextureModelRequest, + TripoRefineModelRequest, + TripoAnimateRigRequest, + TripoAnimateRetargetRequest, + TripoConvertModelRequest, +) + +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 upload_image_to_tripo(image, **kwargs): + urls = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs) + return TripoFileReference(TripoUrlReference(url=urls[0], type="jpeg")) + +def get_model_url_from_response(response: TripoTaskResponse) -> str: + if response.data is not None: + for key in ["pbr_model", "model", "base_model"]: + if getattr(response.data.output, key, None) is not None: + return getattr(response.data.output, key) + raise RuntimeError(f"Failed to get model url from response: {response}") + + +def poll_until_finished( + kwargs: dict[str, str], + response: TripoTaskResponse, +) -> tuple[str, str]: + """Polls the Tripo 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/tripo/v2/openapi/task/{task_id}", + method=HttpMethod.GET, + request_model=EmptyRequest, + response_model=TripoTaskResponse, + ), + completed_statuses=[TripoTaskStatus.SUCCESS], + failed_statuses=[ + TripoTaskStatus.FAILED, + TripoTaskStatus.CANCELLED, + TripoTaskStatus.UNKNOWN, + TripoTaskStatus.BANNED, + TripoTaskStatus.EXPIRED, + ], + 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 == TripoTaskStatus.SUCCESS: + url = get_model_url_from_response(response_poll) + bytesio = download_url_to_bytesio(url) + # Save the downloaded model file + model_file = f"tripo_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 TripoTextToModelNode: + """ + Generates 3D models synchronously based on a text prompt using Tripo's API. + """ + AVERAGE_DURATION = 80 + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ("STRING", {"multiline": True}), + }, + "optional": { + "negative_prompt": ("STRING", {"multiline": True}), + "model_version": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "model_version", enum_type=TripoModelVersion), + "style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"), + "texture": ("BOOLEAN", {"default": True}), + "pbr": ("BOOLEAN", {"default": True}), + "image_seed": ("INT", {"default": 42}), + "model_seed": ("INT", {"default": 42}), + "texture_seed": ("INT", {"default": 42}), + "texture_quality": (["standard", "detailed"], {"default": "standard"}), + "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}), + "quad": ("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/Tripo" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh(self, prompt, negative_prompt=None, model_version=None, style=None, texture=None, pbr=None, image_seed=None, model_seed=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs): + style_enum = None if style == "None" else style + if not prompt: + raise RuntimeError("Prompt is required") + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoTextToModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoTextToModelRequest( + type=TripoTaskType.TEXT_TO_MODEL, + prompt=prompt, + negative_prompt=negative_prompt, + model_version=model_version, + style=style_enum, + texture=texture, + pbr=pbr, + image_seed=image_seed, + model_seed=model_seed, + texture_seed=texture_seed, + texture_quality=texture_quality, + face_limit=face_limit, + auto_size=True, + quad=quad + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +class TripoImageToModelNode: + """ + Generates 3D models synchronously based on a single image using Tripo's API. + """ + AVERAGE_DURATION = 80 + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "model_version": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "model_version", enum_type=TripoModelVersion), + "style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"), + "texture": ("BOOLEAN", {"default": True}), + "pbr": ("BOOLEAN", {"default": True}), + "model_seed": ("INT", {"default": 42}), + "orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation), + "texture_seed": ("INT", {"default": 42}), + "texture_quality": (["standard", "detailed"], {"default": "standard"}), + "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}), + "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}), + "quad": ("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/Tripo" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh(self, image, model_version=None, style=None, texture=None, pbr=None, model_seed=None, orientation=None, texture_alignment=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs): + style_enum = None if style == "None" else style + if image is None: + raise RuntimeError("Image is required") + tripo_file = upload_image_to_tripo(image, **kwargs) + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoImageToModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoImageToModelRequest( + type=TripoTaskType.IMAGE_TO_MODEL, + file=tripo_file, + model_version=model_version, + style=style_enum, + texture=texture, + pbr=pbr, + model_seed=model_seed, + orientation=orientation, + texture_alignment=texture_alignment, + texture_seed=texture_seed, + texture_quality=texture_quality, + face_limit=face_limit, + auto_size=True, + quad=quad + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +class TripoMultiviewToModelNode: + """ + Generates 3D models synchronously based on up to four images (front, left, back, right) using Tripo's API. + """ + AVERAGE_DURATION = 80 + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "image_left": ("IMAGE",), + "image_back": ("IMAGE",), + "image_right": ("IMAGE",), + "model_version": model_field_to_node_input(IO.COMBO, TripoMultiviewToModelRequest, "model_version", enum_type=TripoModelVersion), + "orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation), + "texture": ("BOOLEAN", {"default": True}), + "pbr": ("BOOLEAN", {"default": True}), + "model_seed": ("INT", {"default": 42}), + "texture_seed": ("INT", {"default": 42}), + "texture_quality": (["standard", "detailed"], {"default": "standard"}), + "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}), + "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}), + "quad": ("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/Tripo" + API_NODE = True + OUTPUT_NODE = True + + def generate_mesh(self, image, image_left=None, image_back=None, image_right=None, model_version=None, orientation=None, texture=None, pbr=None, model_seed=None, texture_seed=None, texture_quality=None, texture_alignment=None, face_limit=None, quad=None, **kwargs): + if image is None: + raise RuntimeError("front image for multiview is required") + images = [] + image_dict = { + "image": image, + "image_left": image_left, + "image_back": image_back, + "image_right": image_right + } + for image_name in ["image", "image_left", "image_back", "image_right"]: + image_ = image_dict[image_name] + if image_ is not None: + tripo_file = upload_image_to_tripo(image_, **kwargs) + images.append(tripo_file) + else: + images.append(None) + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoMultiviewToModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoMultiviewToModelRequest( + type=TripoTaskType.MULTIVIEW_TO_MODEL, + images=images, + model_version=model_version, + orientation=orientation, + texture=texture, + pbr=pbr, + model_seed=model_seed, + texture_seed=texture_seed, + texture_quality=texture_quality, + texture_alignment=texture_alignment, + face_limit=face_limit, + quad=quad, + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +class TripoTextureNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model_task_id": ("MODEL_TASK_ID",), + }, + "optional": { + "texture": ("BOOLEAN", {"default": True}), + "pbr": ("BOOLEAN", {"default": True}), + "texture_seed": ("INT", {"default": 42}), + "texture_quality": (["standard", "detailed"], {"default": "standard"}), + "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}), + }, + "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/Tripo" + API_NODE = True + OUTPUT_NODE = True + AVERAGE_DURATION = 80 + + def generate_mesh(self, model_task_id, texture=None, pbr=None, texture_seed=None, texture_quality=None, texture_alignment=None, **kwargs): + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoTextureModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoTextureModelRequest( + original_model_task_id=model_task_id, + texture=texture, + pbr=pbr, + texture_seed=texture_seed, + texture_quality=texture_quality, + texture_alignment=texture_alignment + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + + +class TripoRefineNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model_task_id": ("MODEL_TASK_ID",), + }, + "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/Tripo" + API_NODE = True + OUTPUT_NODE = True + AVERAGE_DURATION = 240 + + def generate_mesh(self, model_task_id, **kwargs): + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoRefineModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoRefineModelRequest( + draft_model_task_id=model_task_id + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + + +class TripoRigNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "original_model_task_id": ("MODEL_TASK_ID",), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("STRING", "RIG_TASK_ID") + RETURN_NAMES = ("model_file", "rig task_id") + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Tripo" + API_NODE = True + OUTPUT_NODE = True + AVERAGE_DURATION = 180 + + def generate_mesh(self, original_model_task_id, **kwargs): + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoAnimateRigRequest, + response_model=TripoTaskResponse, + ), + request=TripoAnimateRigRequest( + original_model_task_id=original_model_task_id, + out_format="glb", + spec="tripo" + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +class TripoRetargetNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "original_model_task_id": ("RIG_TASK_ID",), + "animation": ([ + "preset:idle", + "preset:walk", + "preset:climb", + "preset:jump", + "preset:slash", + "preset:shoot", + "preset:hurt", + "preset:fall", + "preset:turn", + ],), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("STRING", "RETARGET_TASK_ID") + RETURN_NAMES = ("model_file", "retarget task_id") + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Tripo" + API_NODE = True + OUTPUT_NODE = True + AVERAGE_DURATION = 30 + + def generate_mesh(self, animation, original_model_task_id, **kwargs): + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoAnimateRetargetRequest, + response_model=TripoTaskResponse, + ), + request=TripoAnimateRetargetRequest( + original_model_task_id=original_model_task_id, + animation=animation, + out_format="glb", + bake_animation=True + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +class TripoConversionNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "original_model_task_id": ("MODEL_TASK_ID,RIG_TASK_ID,RETARGET_TASK_ID",), + "format": (["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"],), + }, + "optional": { + "quad": ("BOOLEAN", {"default": False}), + "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}), + "texture_size": ("INT", {"min": 128, "max": 4096, "default": 4096}), + "texture_format": (["BMP", "DPX", "HDR", "JPEG", "OPEN_EXR", "PNG", "TARGA", "TIFF", "WEBP"], {"default": "JPEG"}) + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + @classmethod + def VALIDATE_INPUTS(cls, input_types): + # The min and max of input1 and input2 are still validated because + # we didn't take `input1` or `input2` as arguments + if input_types["original_model_task_id"] not in ("MODEL_TASK_ID", "RIG_TASK_ID", "RETARGET_TASK_ID"): + return "original_model_task_id must be MODEL_TASK_ID, RIG_TASK_ID or RETARGET_TASK_ID type" + return True + + RETURN_TYPES = () + FUNCTION = "generate_mesh" + CATEGORY = "api node/3d/Tripo" + API_NODE = True + OUTPUT_NODE = True + AVERAGE_DURATION = 30 + + def generate_mesh(self, original_model_task_id, format, quad, face_limit, texture_size, texture_format, **kwargs): + if not original_model_task_id: + raise RuntimeError("original_model_task_id is required") + response = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/tripo/v2/openapi/task", + method=HttpMethod.POST, + request_model=TripoConvertModelRequest, + response_model=TripoTaskResponse, + ), + request=TripoConvertModelRequest( + original_model_task_id=original_model_task_id, + format=format, + quad=quad, + face_limit=face_limit, + texture_size=texture_size, + texture_format=texture_format + ), + auth_kwargs=kwargs, + ).execute() + return poll_until_finished(kwargs, response) + +NODE_CLASS_MAPPINGS = { + "TripoTextToModelNode": TripoTextToModelNode, + "TripoImageToModelNode": TripoImageToModelNode, + "TripoMultiviewToModelNode": TripoMultiviewToModelNode, + "TripoTextureNode": TripoTextureNode, + "TripoRefineNode": TripoRefineNode, + "TripoRigNode": TripoRigNode, + "TripoRetargetNode": TripoRetargetNode, + "TripoConversionNode": TripoConversionNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "TripoTextToModelNode": "Tripo: Text to Model", + "TripoImageToModelNode": "Tripo: Image to Model", + "TripoMultiviewToModelNode": "Tripo: Multiview to Model", + "TripoTextureNode": "Tripo: Texture model", + "TripoRefineNode": "Tripo: Refine Draft model", + "TripoRigNode": "Tripo: Rig model", + "TripoRetargetNode": "Tripo: Retarget rigged model", + "TripoConversionNode": "Tripo: Convert model", +} diff --git a/nodes.py b/nodes.py index 772e95fac..2d499051e 100644 --- a/nodes.py +++ b/nodes.py @@ -2282,6 +2282,7 @@ def init_builtin_api_nodes(): "nodes_stability.py", "nodes_pika.py", "nodes_runway.py", + "nodes_tripo.py", "nodes_rodin.py", "nodes_gemini.py", ]