""" ComfyUI X Rodin3D(Deemos) API Nodes Rodin API docs: https://developer.hyper3d.ai/ """ import logging import math import os from inspect import cleandoc from io import BytesIO from typing import Any import aiohttp from PIL import Image from typing_extensions import override import folder_paths as comfy_paths from comfy_api.latest import IO, ComfyExtension, Types from comfy_api_nodes.apis.rodin import ( JobStatus, Rodin3DCheckStatusRequest, Rodin3DCheckStatusResponse, Rodin3DDownloadRequest, Rodin3DDownloadResponse, Rodin3DGen25Request, Rodin3DGenerateRequest, Rodin3DGenerateResponse, ) from comfy_api_nodes.util import ( ApiEndpoint, download_url_to_bytesio, download_url_to_file_3d, poll_op, sync_op, validate_string, ) COMMON_PARAMETERS = [ IO.Int.Input( "Seed", default=0, min=0, max=65535, display_mode=IO.NumberDisplay.number, optional=True, ), IO.Combo.Input("Material_Type", options=["PBR", "Shaded"], default="PBR", optional=True), IO.Combo.Input( "Polygon_count", options=["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Triangle"], default="18K-Quad", optional=True, ), ] _QUALITY_MESH_OPTIONS: dict[str, tuple[str, int]] = { "4K-Quad": ("Quad", 4000), "8K-Quad": ("Quad", 8000), "18K-Quad": ("Quad", 18000), "50K-Quad": ("Quad", 50000), "200K-Quad": ("Quad", 200000), "2K-Triangle": ("Raw", 2000), "20K-Triangle": ("Raw", 20000), "150K-Triangle": ("Raw", 150000), "200K-Triangle": ("Raw", 200000), "500K-Triangle": ("Raw", 500000), "1M-Triangle": ("Raw", 1000000), } def get_quality_mode(poly_count: str) -> tuple[str, int]: """Map a polygon-count preset like '18K-Quad' to (mesh_mode, quality_override). Falls back to ('Quad', 18000) for unknown labels; legacy parity. """ return _QUALITY_MESH_OPTIONS.get(poly_count, ("Quad", 18000)) def tensor_to_filelike(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 = BytesIO() image.save(img_byte_arr, format="PNG") # PNG is used for lossless compression img_byte_arr.seek(0) return img_byte_arr async def create_generate_task( cls: type[IO.ComfyNode], images=None, seed=1, material="PBR", quality_override=18000, tier="Regular", mesh_mode="Quad", ta_pose: bool = False, ): 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.") response = await sync_op( cls, ApiEndpoint(path="/proxy/rodin/api/v2/rodin", method="POST"), response_model=Rodin3DGenerateResponse, data=Rodin3DGenerateRequest( seed=seed, tier=tier, material=material, quality_override=quality_override, mesh_mode=mesh_mode, TAPose=ta_pose, ), files=[ ("images", open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image)) for image in images if image is not None ], content_type="multipart/form-data", ) if hasattr(response, "error"): 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("[ Rodin3D API - Submit Jobs ] UUID: %s", task_uuid) return task_uuid, subscription_key def check_rodin_status(response: Rodin3DCheckStatusResponse) -> str: all_done = all(job.status == JobStatus.Done for job in response.jobs) status_list = [str(job.status) for job in response.jobs] logging.info("[ Rodin3D API - CheckStatus ] Generate Status: %s", status_list) if any(job.status == JobStatus.Failed for job in response.jobs): logging.error("[ Rodin3D API - CheckStatus ] Generate Failed: %s, Please try again.", status_list) raise Exception("[ Rodin3D API ] Generate Failed, Please Try again.") if all_done: return "DONE" return "Generating" def extract_progress(response: Rodin3DCheckStatusResponse) -> int | None: if not response.jobs: return None completed_count = sum(1 for job in response.jobs if job.status == JobStatus.Done) return int((completed_count / len(response.jobs)) * 100) async def poll_for_task_status(subscription_key: str, cls: type[IO.ComfyNode]) -> Rodin3DCheckStatusResponse: logging.info("[ Rodin3D API - CheckStatus ] Generate Start!") return await poll_op( cls, ApiEndpoint(path="/proxy/rodin/api/v2/status", method="POST"), response_model=Rodin3DCheckStatusResponse, data=Rodin3DCheckStatusRequest(subscription_key=subscription_key), status_extractor=check_rodin_status, progress_extractor=extract_progress, ) async def get_rodin_download_list(uuid: str, cls: type[IO.ComfyNode]) -> Rodin3DDownloadResponse: logging.info("[ Rodin3D API - Downloading ] Generate Successfully!") return await sync_op( cls, ApiEndpoint(path="/proxy/rodin/api/v2/download", method="POST"), response_model=Rodin3DDownloadResponse, data=Rodin3DDownloadRequest(task_uuid=uuid), monitor_progress=False, ) async def download_files(url_list, task_uuid: str) -> tuple[str | None, Types.File3D | None]: result_folder_name = f"Rodin3D_{task_uuid}" save_path = os.path.join(comfy_paths.get_output_directory(), result_folder_name) os.makedirs(save_path, exist_ok=True) model_file_path = None file_3d = None for i in url_list.items: file_path = os.path.join(save_path, i.name) if i.name.lower().endswith(".glb"): model_file_path = os.path.join(result_folder_name, i.name) file_3d = await download_url_to_file_3d(i.url, "glb") # Save to disk for backward compatibility with open(file_path, "wb") as f: f.write(file_3d.get_bytes()) else: await download_url_to_bytesio(i.url, file_path) return model_file_path, file_3d class Rodin3D_Regular(IO.ComfyNode): """Generate 3D Assets using Rodin API""" @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Regular", display_name="Rodin 3D Generate - Regular Generate", category="api node/3d/Rodin", description=cleandoc(cls.__doc__ or ""), inputs=[ IO.Image.Input("Images"), *COMMON_PARAMETERS, ], outputs=[ IO.String.Output(display_name="3D Model Path"), # for backward compatibility only IO.File3DGLB.Output(display_name="GLB"), ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id, ], is_api_node=True, price_badge=IO.PriceBadge( expr="""{"type":"usd","usd":0.4}""", ), ) @classmethod async def execute( cls, Images, Seed, Material_Type, Polygon_count, ) -> IO.NodeOutput: tier = "Regular" num_images = Images.shape[0] m_images = [] for i in range(num_images): m_images.append(Images[i]) mesh_mode, quality_override = get_quality_mode(Polygon_count) task_uuid, subscription_key = await create_generate_task( cls, images=m_images, seed=Seed, material=Material_Type, quality_override=quality_override, tier=tier, mesh_mode=mesh_mode, ) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) model_path, file_3d = await download_files(download_list, task_uuid) return IO.NodeOutput(model_path, file_3d) class Rodin3D_Detail(IO.ComfyNode): """Generate 3D Assets using Rodin API""" @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Detail", display_name="Rodin 3D Generate - Detail Generate", category="api node/3d/Rodin", description=cleandoc(cls.__doc__ or ""), inputs=[ IO.Image.Input("Images"), *COMMON_PARAMETERS, ], outputs=[ IO.String.Output(display_name="3D Model Path"), # for backward compatibility only IO.File3DGLB.Output(display_name="GLB"), ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id, ], is_api_node=True, price_badge=IO.PriceBadge( expr="""{"type":"usd","usd":0.4}""", ), ) @classmethod async def execute( cls, Images, Seed, Material_Type, Polygon_count, ) -> IO.NodeOutput: tier = "Detail" num_images = Images.shape[0] m_images = [] for i in range(num_images): m_images.append(Images[i]) mesh_mode, quality_override = get_quality_mode(Polygon_count) task_uuid, subscription_key = await create_generate_task( cls, images=m_images, seed=Seed, material=Material_Type, quality_override=quality_override, tier=tier, mesh_mode=mesh_mode, ) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) model_path, file_3d = await download_files(download_list, task_uuid) return IO.NodeOutput(model_path, file_3d) class Rodin3D_Smooth(IO.ComfyNode): """Generate 3D Assets using Rodin API""" @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Smooth", display_name="Rodin 3D Generate - Smooth Generate", category="api node/3d/Rodin", description=cleandoc(cls.__doc__ or ""), inputs=[ IO.Image.Input("Images"), *COMMON_PARAMETERS, ], outputs=[ IO.String.Output(display_name="3D Model Path"), # for backward compatibility only IO.File3DGLB.Output(display_name="GLB"), ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id, ], is_api_node=True, price_badge=IO.PriceBadge( expr="""{"type":"usd","usd":0.4}""", ), ) @classmethod async def execute( cls, Images, Seed, Material_Type, Polygon_count, ) -> IO.NodeOutput: num_images = Images.shape[0] m_images = [] for i in range(num_images): m_images.append(Images[i]) mesh_mode, quality_override = get_quality_mode(Polygon_count) task_uuid, subscription_key = await create_generate_task( cls, images=m_images, seed=Seed, material=Material_Type, quality_override=quality_override, tier="Smooth", mesh_mode=mesh_mode, ) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) model_path, file_3d = await download_files(download_list, task_uuid) return IO.NodeOutput(model_path, file_3d) class Rodin3D_Sketch(IO.ComfyNode): """Generate 3D Assets using Rodin API""" @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Sketch", display_name="Rodin 3D Generate - Sketch Generate", category="api node/3d/Rodin", description=cleandoc(cls.__doc__ or ""), inputs=[ IO.Image.Input("Images"), IO.Int.Input( "Seed", default=0, min=0, max=65535, display_mode=IO.NumberDisplay.number, optional=True, ), ], outputs=[ IO.String.Output(display_name="3D Model Path"), # for backward compatibility only IO.File3DGLB.Output(display_name="GLB"), ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id, ], is_api_node=True, price_badge=IO.PriceBadge( expr="""{"type":"usd","usd":0.4}""", ), ) @classmethod async def execute( cls, Images, Seed, ) -> IO.NodeOutput: num_images = Images.shape[0] m_images = [] for i in range(num_images): m_images.append(Images[i]) task_uuid, subscription_key = await create_generate_task( cls, images=m_images, seed=Seed, material="PBR", quality_override=18000, tier="Sketch", mesh_mode="Quad", ) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) model_path, file_3d = await download_files(download_list, task_uuid) return IO.NodeOutput(model_path, file_3d) class Rodin3D_Gen2(IO.ComfyNode): """Generate 3D Assets using Rodin API""" @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Gen2", display_name="Rodin 3D Generate - Gen-2 Generate", category="api node/3d/Rodin", description=cleandoc(cls.__doc__ or ""), inputs=[ IO.Image.Input("Images"), IO.Int.Input( "Seed", default=0, min=0, max=65535, display_mode=IO.NumberDisplay.number, optional=True, ), IO.Combo.Input("Material_Type", options=["PBR", "Shaded"], default="PBR", optional=True), IO.Combo.Input( "Polygon_count", options=[ "4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle", ], default="500K-Triangle", optional=True, ), IO.Boolean.Input("TAPose", default=False, advanced=True), ], outputs=[ IO.String.Output(display_name="3D Model Path"), # for backward compatibility only IO.File3DGLB.Output(display_name="GLB"), ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id, ], is_api_node=True, price_badge=IO.PriceBadge( expr="""{"type":"usd","usd":0.4}""", ), ) @classmethod async def execute( cls, Images, Seed, Material_Type, Polygon_count, TAPose, ) -> IO.NodeOutput: tier = "Gen-2" num_images = Images.shape[0] m_images = [] for i in range(num_images): m_images.append(Images[i]) mesh_mode, quality_override = get_quality_mode(Polygon_count) task_uuid, subscription_key = await create_generate_task( cls, images=m_images, seed=Seed, material=Material_Type, quality_override=quality_override, tier=tier, mesh_mode=mesh_mode, ta_pose=TAPose, ) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) model_path, file_3d = await download_files(download_list, task_uuid) return IO.NodeOutput(model_path, file_3d) def _rodin_multipart_parser(data: dict[str, Any]) -> aiohttp.FormData: """Convert a Rodin request dict to an aiohttp form, fixing bool/list serialization. Booleans --> "true"/"false". Lists --> one field per element. """ form = aiohttp.FormData(default_to_multipart=True) for key, value in data.items(): if value is None: continue if isinstance(value, bool): form.add_field(key, "true" if value else "false") elif isinstance(value, list): for item in value: form.add_field(key, str(item)) elif isinstance(value, (bytes, bytearray)): form.add_field(key, value) else: form.add_field(key, str(value)) return form async def _create_gen25_task( cls: type[IO.ComfyNode], request: Rodin3DGen25Request, images: list | None, ) -> tuple[str, str]: """Submit a Gen-2.5 generate job; returns (task_uuid, subscription_key).""" if images is not None and len(images) > 5: raise ValueError("Rodin Gen-2.5 supports at most 5 input images.") files = None if images: files = [ ( "images", open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image), ) for image in images if image is not None ] response = await sync_op( cls, ApiEndpoint(path="/proxy/rodin/api/v2/rodin", method="POST"), response_model=Rodin3DGenerateResponse, data=request, files=files, content_type="multipart/form-data", multipart_parser=_rodin_multipart_parser, ) if not response.uuid or not response.jobs or not response.jobs.subscription_key: raise RuntimeError(f"Rodin Gen-2.5 submit failed: message={response.message!r}") return response.uuid, response.jobs.subscription_key _PREVIEWABLE_3D_EXTS = {".glb", ".obj", ".fbx", ".stl", ".gltf"} async def _download_gen25_files( download_list: Rodin3DDownloadResponse, task_uuid: str, geometry_file_format: str, ) -> Types.File3D | None: """Download every file in the list; return the File3D matching the chosen format.""" folder_name = f"Rodin3D_Gen25_{task_uuid}" save_dir = os.path.join(comfy_paths.get_output_directory(), folder_name) os.makedirs(save_dir, exist_ok=True) target_ext = f".{geometry_file_format.lower().lstrip('.')}" file_3d: Types.File3D | None = None for item in download_list.items: file_path = os.path.join(save_dir, item.name) ext = os.path.splitext(item.name.lower())[1] # Prefer the file matching the user's chosen format; fall back below. if file_3d is None and ext == target_ext and ext in _PREVIEWABLE_3D_EXTS: file_3d = await download_url_to_file_3d(item.url, target_ext.lstrip(".")) with open(file_path, "wb") as f: f.write(file_3d.get_bytes()) continue await download_url_to_bytesio(item.url, file_path) # If the chosen format wasn't found, surface any model file we did get. if file_3d is None: for item in download_list.items: ext = os.path.splitext(item.name.lower())[1] if ext in _PREVIEWABLE_3D_EXTS: file_3d = await download_url_to_file_3d(item.url, ext.lstrip(".")) break return file_3d _MODE_REGULAR = "Regular" _MODE_FAST = "Fast" _MODE_EXTREME_HIGH = "Extreme-High" _REGULAR_POLY_OPTIONS = [ "Default", "4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle", "1M-Triangle", ] _TEXTURE_MODE_OPTIONS = ["Default", "legacy", "extreme-low", "low", "medium", "high"] _GEOMETRY_FORMAT_OPTIONS = ["glb", "fbx", "obj", "stl"] _MATERIAL_OPTIONS = ["PBR", "Shaded", "All", "None"] def _build_mode_input(name: str = "mode") -> IO.DynamicCombo.Input: return IO.DynamicCombo.Input( name, options=[ IO.DynamicCombo.Option( _MODE_REGULAR, [ IO.Combo.Input( "tier", options=["Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], default="Gen-2.5-High", tooltip="Quality tier. Higher tiers produce higher-fidelity geometry.", ), IO.Combo.Input( "polygon_count", options=_REGULAR_POLY_OPTIONS, default="Default", tooltip="Preset face count. 'Default' uses the server's default for the selected tier.", ), IO.Boolean.Input( "creative", default=False, tooltip="Creative mode (Medium/High only). Enhances generative robustness.", ), ], ), IO.DynamicCombo.Option( _MODE_FAST, [ IO.Combo.Input( "tier", options=[ "Gen-2.5-Extreme-Low", "Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High", ], default="Gen-2.5-Low", ), IO.Int.Input( "mesh_faces", default=20000, min=1000, max=20000, display_mode=IO.NumberDisplay.number, tooltip="Mesh face count (1K-20K in Fast mode).", ), ], ), IO.DynamicCombo.Option( _MODE_EXTREME_HIGH, [ IO.Combo.Input("mesh_mode", options=["Raw", "Quad"], default="Raw"), IO.Int.Input( "mesh_faces", default=1000000, min=20000, max=2000000, display_mode=IO.NumberDisplay.number, tooltip=( "Mesh face count. Raw mode: 20K-2M. " "Quad mode: keep under 200K (upstream may reject higher values)." ), ), IO.Boolean.Input( "is_micro", default=False, tooltip="Enable micro detail (Extreme-High only).", ), IO.Boolean.Input( "creative", default=False, tooltip="Creative mode. Enhances generative robustness.", ), ], ), ], tooltip=( "Generation mode. Regular = balanced. Fast = 1K-20K faces for rapid prototyping. " "Extreme-High = 20K-2M faces with optional micro details." ), ) def _build_common_inputs(*, include_image_only: bool) -> list: inputs: list = [ IO.Combo.Input("material", options=_MATERIAL_OPTIONS, default="Shaded"), IO.Combo.Input("geometry_file_format", options=_GEOMETRY_FORMAT_OPTIONS, default="glb"), IO.Combo.Input( "texture_mode", options=_TEXTURE_MODE_OPTIONS, default="Default", optional=True, tooltip="Texture quality preset. 'Default' uses the server's default for the selected tier.", ), IO.Int.Input( "seed", default=0, min=0, max=65535, display_mode=IO.NumberDisplay.number, control_after_generate=True, optional=True, ), IO.Boolean.Input( "TAPose", default=False, optional=True, advanced=True, tooltip="T/A pose for human-like models." ), IO.Boolean.Input( "hd_texture", default=False, optional=True, advanced=True, tooltip="High-quality texture enhancement." ), IO.Boolean.Input( "texture_delight", default=False, optional=True, advanced=True, tooltip="Remove baked lighting from textures.", ), ] if include_image_only: inputs.append( IO.Boolean.Input( "use_original_alpha", default=False, optional=True, advanced=True, tooltip="Preserve image transparency.", ) ) inputs.extend( [ IO.Boolean.Input( "addon_highpack", default=False, optional=True, advanced=True, tooltip="HighPack addon: 4K textures and ~16x faces in Quad mode.", ), IO.Int.Input( "bbox_width", default=0, min=0, max=300, display_mode=IO.NumberDisplay.number, optional=True, advanced=True, tooltip="Bounding-box width (Y axis). Set to 0 with the others to skip bbox.", ), IO.Int.Input( "bbox_height", default=0, min=0, max=300, display_mode=IO.NumberDisplay.number, optional=True, advanced=True, tooltip="Bounding-box height (Z axis).", ), IO.Int.Input( "bbox_length", default=0, min=0, max=300, display_mode=IO.NumberDisplay.number, optional=True, advanced=True, tooltip="Bounding-box length (X axis).", ), IO.Int.Input( "height_cm", default=0, min=0, max=10000, display_mode=IO.NumberDisplay.number, optional=True, advanced=True, tooltip="Approximate model height in centimeters (0 to skip).", ), ] ) return inputs _PRICE_EXPR = """ ( $baseCredits := widgets.mode = "extreme-high" ? 1.0 : 0.5; $addonCredits := widgets.addon_highpack ? 1.0 : 0.0; $total := ($baseCredits * 1.5) + ($addonCredits * 0.8); {"type":"usd","usd": $total} ) """ def _resolve_mode_params(mode_input: dict) -> dict: """Translate the DynamicCombo `mode` payload into Gen-2.5 request fields. Returns a dict with: tier, quality_override, mesh_mode, geometry_instruct_mode, is_micro. Missing keys mean "do not send" (so we don't override server defaults). """ selected = mode_input["mode"] out: dict = {} if selected == _MODE_REGULAR: out["tier"] = mode_input["tier"] polygon = mode_input.get("polygon_count", "Default") if polygon != "Default": mesh_mode, faces = get_quality_mode(polygon) out["mesh_mode"] = mesh_mode out["quality_override"] = faces if mode_input.get("creative"): out["geometry_instruct_mode"] = "creative" elif selected == _MODE_FAST: out["tier"] = mode_input["tier"] out["mesh_mode"] = "Raw" out["quality_override"] = int(mode_input["mesh_faces"]) elif selected == _MODE_EXTREME_HIGH: out["tier"] = "Gen-2.5-Extreme-High" out["mesh_mode"] = mode_input["mesh_mode"] out["quality_override"] = int(mode_input["mesh_faces"]) if mode_input.get("is_micro"): out["is_micro"] = True if mode_input.get("creative"): out["geometry_instruct_mode"] = "creative" return out def _build_request( *, mode_input: dict, material: str, geometry_file_format: str, texture_mode: str, seed: int, TAPose: bool, hd_texture: bool, texture_delight: bool, addon_highpack: bool, bbox_width: int, bbox_height: int, bbox_length: int, height_cm: int, prompt: str | None = None, use_original_alpha: bool = False, ) -> Rodin3DGen25Request: mode_params = _resolve_mode_params(mode_input) bbox = None if bbox_width and bbox_height and bbox_length: bbox = [bbox_width, bbox_height, bbox_length] return Rodin3DGen25Request( tier=mode_params["tier"], prompt=prompt or None, seed=seed, material=material, geometry_file_format=geometry_file_format, texture_mode=None if texture_mode == "Default" else texture_mode, mesh_mode=mode_params.get("mesh_mode"), quality_override=mode_params.get("quality_override"), geometry_instruct_mode=mode_params.get("geometry_instruct_mode"), bbox_condition=bbox, height=height_cm or None, TAPose=TAPose or None, hd_texture=hd_texture or None, texture_delight=texture_delight or None, is_micro=mode_params.get("is_micro"), use_original_alpha=use_original_alpha or None, addons=["HighPack"] if addon_highpack else None, ) class Rodin3D_Gen25_Image(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Gen25_Image", display_name="Rodin 3D Gen-2.5 - Image to 3D", category="api node/3d/Rodin", description=( "Generate a 3D model from 1-5 reference images via Rodin Gen-2.5. " "Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost." ), inputs=[ IO.Autogrow.Input( "images", template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=1, max=5), tooltip="1-5 images. The first image is used for materials when multi-view.", ), _build_mode_input(), *_build_common_inputs(include_image_only=True), ], outputs=[IO.File3DAny.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, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]), expr=_PRICE_EXPR, ), ) @classmethod async def execute( cls, images: IO.Autogrow.Type, mode: dict, material: str, geometry_file_format: str, texture_mode: str, seed: int, TAPose: bool, hd_texture: bool, texture_delight: bool, use_original_alpha: bool, addon_highpack: bool, bbox_width: int, bbox_height: int, bbox_length: int, height_cm: int, ) -> IO.NodeOutput: image_tensors = [img for img in images.values() if img is not None] if not image_tensors: raise ValueError("Rodin Gen-2.5 Image-to-3D requires at least one image.") # Flatten multi-image tensors into individual frames; the API accepts each as a separate part. flat_images: list = [] for tensor in image_tensors: if hasattr(tensor, "shape") and len(tensor.shape) == 4: for i in range(tensor.shape[0]): flat_images.append(tensor[i]) else: flat_images.append(tensor) if len(flat_images) > 5: raise ValueError(f"Rodin Gen-2.5 accepts at most 5 images; received {len(flat_images)}.") request = _build_request( mode_input=mode, material=material, geometry_file_format=geometry_file_format, texture_mode=texture_mode, seed=seed, TAPose=TAPose, hd_texture=hd_texture, texture_delight=texture_delight, addon_highpack=addon_highpack, bbox_width=bbox_width, bbox_height=bbox_height, bbox_length=bbox_length, height_cm=height_cm, prompt=None, use_original_alpha=use_original_alpha, ) task_uuid, subscription_key = await _create_gen25_task(cls, request, flat_images) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format) return IO.NodeOutput(file_3d) class Rodin3D_Gen25_Text(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="Rodin3D_Gen25_Text", display_name="Rodin 3D Gen-2.5 - Text to 3D", category="api node/3d/Rodin", description=( "Generate a 3D model from a text prompt via Rodin Gen-2.5. " "Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost." ), inputs=[ IO.String.Input( "prompt", multiline=True, default="", tooltip="Text prompt for the 3D model.", ), _build_mode_input(), *_build_common_inputs(include_image_only=False), ], outputs=[IO.File3DAny.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, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]), expr=_PRICE_EXPR, ), ) @classmethod async def execute( cls, prompt: str, mode: dict, material: str, geometry_file_format: str, texture_mode: str, seed: int, TAPose: bool, hd_texture: bool, texture_delight: bool, addon_highpack: bool, bbox_width: int, bbox_height: int, bbox_length: int, height_cm: int, ) -> IO.NodeOutput: validate_string(prompt, field_name="prompt", min_length=1, max_length=2500) request = _build_request( mode_input=mode, material=material, geometry_file_format=geometry_file_format, texture_mode=texture_mode, seed=seed, TAPose=TAPose, hd_texture=hd_texture, texture_delight=texture_delight, addon_highpack=addon_highpack, bbox_width=bbox_width, bbox_height=bbox_height, bbox_length=bbox_length, height_cm=height_cm, prompt=prompt, ) task_uuid, subscription_key = await _create_gen25_task(cls, request, images=None) await poll_for_task_status(subscription_key, cls) download_list = await get_rodin_download_list(task_uuid, cls) file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format) return IO.NodeOutput(file_3d) class Rodin3DExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ Rodin3D_Regular, Rodin3D_Detail, Rodin3D_Smooth, Rodin3D_Sketch, Rodin3D_Gen2, Rodin3D_Gen25_Image, Rodin3D_Gen25_Text, ] async def comfy_entrypoint() -> Rodin3DExtension: return Rodin3DExtension()