| """ |
| ComfyUI X Rodin3D(Deemos) API Nodes |
| |
| Rodin API docs: https://developer.hyper3d.ai/ |
| |
| """ |
|
|
| from __future__ import annotations |
| from inspect import cleandoc |
| from comfy.comfy_types.node_typing import IO |
| import folder_paths as comfy_paths |
| import aiohttp |
| import os |
| import datetime |
| import asyncio |
| import io |
| import logging |
| import math |
| from PIL import Image |
| from comfy_api_nodes.apis.rodin_api import ( |
| Rodin3DGenerateRequest, |
| Rodin3DGenerateResponse, |
| Rodin3DCheckStatusRequest, |
| Rodin3DCheckStatusResponse, |
| Rodin3DDownloadRequest, |
| Rodin3DDownloadResponse, |
| JobStatus, |
| ) |
| from comfy_api_nodes.apis.client import ( |
| ApiEndpoint, |
| HttpMethod, |
| SynchronousOperation, |
| PollingOperation, |
| ) |
|
|
|
|
| COMMON_PARAMETERS = { |
| "Seed": ( |
| IO.INT, |
| { |
| "default":0, |
| "min":0, |
| "max":65535, |
| "display":"number" |
| } |
| ), |
| "Material_Type": ( |
| IO.COMBO, |
| { |
| "options": ["PBR", "Shaded"], |
| "default": "PBR" |
| } |
| ), |
| "Polygon_count": ( |
| IO.COMBO, |
| { |
| "options": ["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Triangle"], |
| "default": "18K-Quad" |
| } |
| ) |
| } |
|
|
| def create_task_error(response: Rodin3DGenerateResponse): |
| """Check if the response has error""" |
| return hasattr(response, "error") |
|
|
|
|
| class Rodin3DAPI: |
| """ |
| Generate 3D Assets using Rodin API |
| """ |
| RETURN_TYPES = (IO.STRING,) |
| RETURN_NAMES = ("3D Model Path",) |
| CATEGORY = "api node/3d/Rodin" |
| DESCRIPTION = cleandoc(__doc__ or "") |
| FUNCTION = "api_call" |
| API_NODE = True |
|
|
| def tensor_to_filelike(self, tensor, max_pixels: int = 2048*2048): |
| """ |
| Converts a PyTorch tensor to a file-like object. |
| |
| Args: |
| - tensor (torch.Tensor): A tensor representing an image of shape (H, W, C) |
| where C is the number of channels (3 for RGB), H is height, and W is width. |
| |
| Returns: |
| - io.BytesIO: A file-like object containing the image data. |
| """ |
| array = tensor.cpu().numpy() |
| array = (array * 255).astype('uint8') |
| image = Image.fromarray(array, 'RGB') |
|
|
| original_width, original_height = image.size |
| original_pixels = original_width * original_height |
| if original_pixels > max_pixels: |
| scale = math.sqrt(max_pixels / original_pixels) |
| new_width = int(original_width * scale) |
| new_height = int(original_height * scale) |
| else: |
| new_width, new_height = original_width, original_height |
|
|
| if new_width != original_width or new_height != original_height: |
| image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) |
|
|
| img_byte_arr = io.BytesIO() |
| image.save(img_byte_arr, format='PNG') |
| img_byte_arr.seek(0) |
| return img_byte_arr |
|
|
| def check_rodin_status(self, response: Rodin3DCheckStatusResponse) -> str: |
| has_failed = any(job.status == JobStatus.Failed for job in response.jobs) |
| all_done = all(job.status == JobStatus.Done for job in response.jobs) |
| status_list = [str(job.status) for job in response.jobs] |
| logging.info(f"[ Rodin3D API - CheckStatus ] Generate Status: {status_list}") |
| if has_failed: |
| logging.error(f"[ Rodin3D API - CheckStatus ] Generate Failed: {status_list}, Please try again.") |
| raise Exception("[ Rodin3D API ] Generate Failed, Please Try again.") |
| elif all_done: |
| return "DONE" |
| else: |
| return "Generating" |
|
|
| async def create_generate_task(self, images=None, seed=1, material="PBR", quality="medium", tier="Regular", mesh_mode="Quad", **kwargs): |
| if images is None: |
| raise Exception("Rodin 3D generate requires at least 1 image.") |
| if len(images) >= 5: |
| raise Exception("Rodin 3D generate requires up to 5 image.") |
|
|
| path = "/proxy/rodin/api/v2/rodin" |
| operation = SynchronousOperation( |
| endpoint=ApiEndpoint( |
| path=path, |
| method=HttpMethod.POST, |
| request_model=Rodin3DGenerateRequest, |
| response_model=Rodin3DGenerateResponse, |
| ), |
| request=Rodin3DGenerateRequest( |
| seed=seed, |
| tier=tier, |
| material=material, |
| quality=quality, |
| mesh_mode=mesh_mode |
| ), |
| files=[ |
| ( |
| "images", |
| open(image, "rb") if isinstance(image, str) else self.tensor_to_filelike(image) |
| ) |
| for image in images if image is not None |
| ], |
| content_type = "multipart/form-data", |
| auth_kwargs=kwargs, |
| ) |
|
|
| response = await operation.execute() |
|
|
| if create_task_error(response): |
| error_message = f"Rodin3D Create 3D generate Task Failed. Message: {response.message}, error: {response.error}" |
| logging.error(error_message) |
| raise Exception(error_message) |
|
|
| logging.info("[ Rodin3D API - Submit Jobs ] Submit Generate Task Success!") |
| subscription_key = response.jobs.subscription_key |
| task_uuid = response.uuid |
| logging.info(f"[ Rodin3D API - Submit Jobs ] UUID: {task_uuid}") |
| return task_uuid, subscription_key |
|
|
| async def poll_for_task_status(self, subscription_key, **kwargs) -> Rodin3DCheckStatusResponse: |
|
|
| path = "/proxy/rodin/api/v2/status" |
|
|
| poll_operation = PollingOperation( |
| poll_endpoint=ApiEndpoint( |
| path = path, |
| method=HttpMethod.POST, |
| request_model=Rodin3DCheckStatusRequest, |
| response_model=Rodin3DCheckStatusResponse, |
| ), |
| request=Rodin3DCheckStatusRequest( |
| subscription_key = subscription_key |
| ), |
| completed_statuses=["DONE"], |
| failed_statuses=["FAILED"], |
| status_extractor=self.check_rodin_status, |
| poll_interval=3.0, |
| auth_kwargs=kwargs, |
| ) |
|
|
| logging.info("[ Rodin3D API - CheckStatus ] Generate Start!") |
|
|
| return await poll_operation.execute() |
|
|
| async def get_rodin_download_list(self, uuid, **kwargs) -> Rodin3DDownloadResponse: |
| logging.info("[ Rodin3D API - Downloading ] Generate Successfully!") |
|
|
| path = "/proxy/rodin/api/v2/download" |
| operation = SynchronousOperation( |
| endpoint=ApiEndpoint( |
| path=path, |
| method=HttpMethod.POST, |
| request_model=Rodin3DDownloadRequest, |
| response_model=Rodin3DDownloadResponse, |
| ), |
| request=Rodin3DDownloadRequest( |
| task_uuid=uuid |
| ), |
| auth_kwargs=kwargs |
| ) |
|
|
| return await operation.execute() |
|
|
| def get_quality_mode(self, poly_count): |
| if poly_count == "200K-Triangle": |
| mesh_mode = "Raw" |
| quality = "medium" |
| else: |
| mesh_mode = "Quad" |
| if poly_count == "4K-Quad": |
| quality = "extra-low" |
| elif poly_count == "8K-Quad": |
| quality = "low" |
| elif poly_count == "18K-Quad": |
| quality = "medium" |
| elif poly_count == "50K-Quad": |
| quality = "high" |
| else: |
| quality = "medium" |
|
|
| return mesh_mode, quality |
|
|
| async def download_files(self, url_list): |
| save_path = os.path.join(comfy_paths.get_output_directory(), "Rodin3D", datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) |
| os.makedirs(save_path, exist_ok=True) |
| model_file_path = None |
| async with aiohttp.ClientSession() as session: |
| for i in url_list.list: |
| url = i.url |
| file_name = i.name |
| file_path = os.path.join(save_path, file_name) |
| if file_path.endswith(".glb"): |
| model_file_path = file_path |
| logging.info(f"[ Rodin3D API - download_files ] Downloading file: {file_path}") |
| max_retries = 5 |
| for attempt in range(max_retries): |
| try: |
| async with session.get(url) as resp: |
| resp.raise_for_status() |
| with open(file_path, "wb") as f: |
| async for chunk in resp.content.iter_chunked(32 * 1024): |
| f.write(chunk) |
| break |
| except Exception as e: |
| logging.info(f"[ Rodin3D API - download_files ] Error downloading {file_path}:{e}") |
| if attempt < max_retries - 1: |
| logging.info("Retrying...") |
| await asyncio.sleep(2) |
| else: |
| logging.info( |
| "[ Rodin3D API - download_files ] Failed to download %s after %s attempts.", |
| file_path, |
| max_retries, |
| ) |
|
|
| return model_file_path |
|
|
|
|
| class Rodin3D_Regular(Rodin3DAPI): |
| @classmethod |
| def INPUT_TYPES(s): |
| return { |
| "required": { |
| "Images": |
| ( |
| IO.IMAGE, |
| { |
| "forceInput":True, |
| } |
| ) |
| }, |
| "optional": { |
| **COMMON_PARAMETERS |
| }, |
| "hidden": { |
| "auth_token": "AUTH_TOKEN_COMFY_ORG", |
| "comfy_api_key": "API_KEY_COMFY_ORG", |
| }, |
| } |
|
|
| async def api_call( |
| self, |
| Images, |
| Seed, |
| Material_Type, |
| Polygon_count, |
| **kwargs |
| ): |
| tier = "Regular" |
| num_images = Images.shape[0] |
| m_images = [] |
| for i in range(num_images): |
| m_images.append(Images[i]) |
| mesh_mode, quality = self.get_quality_mode(Polygon_count) |
| task_uuid, subscription_key = await self.create_generate_task(images=m_images, seed=Seed, material=Material_Type, |
| quality=quality, tier=tier, mesh_mode=mesh_mode, |
| **kwargs) |
| await self.poll_for_task_status(subscription_key, **kwargs) |
| download_list = await self.get_rodin_download_list(task_uuid, **kwargs) |
| model = await self.download_files(download_list) |
|
|
| return (model,) |
|
|
|
|
| class Rodin3D_Detail(Rodin3DAPI): |
| @classmethod |
| def INPUT_TYPES(s): |
| return { |
| "required": { |
| "Images": |
| ( |
| IO.IMAGE, |
| { |
| "forceInput":True, |
| } |
| ) |
| }, |
| "optional": { |
| **COMMON_PARAMETERS |
| }, |
| "hidden": { |
| "auth_token": "AUTH_TOKEN_COMFY_ORG", |
| "comfy_api_key": "API_KEY_COMFY_ORG", |
| }, |
| } |
|
|
| async def api_call( |
| self, |
| Images, |
| Seed, |
| Material_Type, |
| Polygon_count, |
| **kwargs |
| ): |
| tier = "Detail" |
| num_images = Images.shape[0] |
| m_images = [] |
| for i in range(num_images): |
| m_images.append(Images[i]) |
| mesh_mode, quality = self.get_quality_mode(Polygon_count) |
| task_uuid, subscription_key = await self.create_generate_task(images=m_images, seed=Seed, material=Material_Type, |
| quality=quality, tier=tier, mesh_mode=mesh_mode, |
| **kwargs) |
| await self.poll_for_task_status(subscription_key, **kwargs) |
| download_list = await self.get_rodin_download_list(task_uuid, **kwargs) |
| model = await self.download_files(download_list) |
|
|
| return (model,) |
|
|
|
|
| class Rodin3D_Smooth(Rodin3DAPI): |
| @classmethod |
| def INPUT_TYPES(s): |
| return { |
| "required": { |
| "Images": |
| ( |
| IO.IMAGE, |
| { |
| "forceInput":True, |
| } |
| ) |
| }, |
| "optional": { |
| **COMMON_PARAMETERS |
| }, |
| "hidden": { |
| "auth_token": "AUTH_TOKEN_COMFY_ORG", |
| "comfy_api_key": "API_KEY_COMFY_ORG", |
| }, |
| } |
|
|
| async def api_call( |
| self, |
| Images, |
| Seed, |
| Material_Type, |
| Polygon_count, |
| **kwargs |
| ): |
| tier = "Smooth" |
| num_images = Images.shape[0] |
| m_images = [] |
| for i in range(num_images): |
| m_images.append(Images[i]) |
| mesh_mode, quality = self.get_quality_mode(Polygon_count) |
| task_uuid, subscription_key = await self.create_generate_task(images=m_images, seed=Seed, material=Material_Type, |
| quality=quality, tier=tier, mesh_mode=mesh_mode, |
| **kwargs) |
| await self.poll_for_task_status(subscription_key, **kwargs) |
| download_list = await self.get_rodin_download_list(task_uuid, **kwargs) |
| model = await self.download_files(download_list) |
|
|
| return (model,) |
|
|
|
|
| class Rodin3D_Sketch(Rodin3DAPI): |
| @classmethod |
| def INPUT_TYPES(s): |
| return { |
| "required": { |
| "Images": |
| ( |
| IO.IMAGE, |
| { |
| "forceInput":True, |
| } |
| ) |
| }, |
| "optional": { |
| "Seed": |
| ( |
| IO.INT, |
| { |
| "default":0, |
| "min":0, |
| "max":65535, |
| "display":"number" |
| } |
| ) |
| }, |
| "hidden": { |
| "auth_token": "AUTH_TOKEN_COMFY_ORG", |
| "comfy_api_key": "API_KEY_COMFY_ORG", |
| }, |
| } |
|
|
| async def api_call( |
| self, |
| Images, |
| Seed, |
| **kwargs |
| ): |
| tier = "Sketch" |
| num_images = Images.shape[0] |
| m_images = [] |
| for i in range(num_images): |
| m_images.append(Images[i]) |
| material_type = "PBR" |
| quality = "medium" |
| mesh_mode = "Quad" |
| task_uuid, subscription_key = await self.create_generate_task( |
| images=m_images, seed=Seed, material=material_type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs |
| ) |
| await self.poll_for_task_status(subscription_key, **kwargs) |
| download_list = await self.get_rodin_download_list(task_uuid, **kwargs) |
| model = await self.download_files(download_list) |
|
|
| return (model,) |
|
|
| |
| |
| NODE_CLASS_MAPPINGS = { |
| "Rodin3D_Regular": Rodin3D_Regular, |
| "Rodin3D_Detail": Rodin3D_Detail, |
| "Rodin3D_Smooth": Rodin3D_Smooth, |
| "Rodin3D_Sketch": Rodin3D_Sketch, |
| } |
|
|
| |
| NODE_DISPLAY_NAME_MAPPINGS = { |
| "Rodin3D_Regular": "Rodin 3D Generate - Regular Generate", |
| "Rodin3D_Detail": "Rodin 3D Generate - Detail Generate", |
| "Rodin3D_Smooth": "Rodin 3D Generate - Smooth Generate", |
| "Rodin3D_Sketch": "Rodin 3D Generate - Sketch Generate", |
| } |
|
|