diff --git a/comfy_api_nodes/apis/tripo.py b/comfy_api_nodes/apis/tripo.py index 4b108b099..583c2f229 100644 --- a/comfy_api_nodes/apis/tripo.py +++ b/comfy_api_nodes/apis/tripo.py @@ -8,7 +8,11 @@ class TripoModelVersion(str, Enum): v3_1_20260211 = "v3.1-20260211" v3_0_20250812 = "v3.0-20250812" v2_5_20250123 = "v2.5-20250123" - v1_4_20240625 = "v1.4-20240625" + + +class TripoTextureModelVersion(str, Enum): + v3_0_20250812 = "v3.0-20250812" + v2_5_20250123 = "v2.5-20250123" class TripoGeometryQuality(str, Enum): @@ -19,6 +23,7 @@ class TripoGeometryQuality(str, Enum): class TripoTextureQuality(str, Enum): standard = "standard" detailed = "detailed" + extreme = "extreme" class TripoStyle(str, Enum): @@ -43,6 +48,11 @@ class TripoTaskType(str, Enum): ANIMATE_RETARGET = "animate_retarget" STYLIZE_MODEL = "stylize_model" CONVERT_MODEL = "convert_model" + MESH_SEGMENTATION = "mesh_segmentation" + MESH_COMPLETION = "mesh_completion" + HIGHPOLY_TO_LOWPOLY = "highpoly_to_lowpoly" + GENERATE_MULTIVIEW_IMAGE = "generate_multiview_image" + EDIT_MULTIVIEW_IMAGE = "edit_multiview_image" class TripoTextureAlignment(str, Enum): @@ -65,6 +75,21 @@ class TripoSpec(str, Enum): TRIPO = "tripo" +class TripoRigModelVersion(str, Enum): + v1_0_20240301 = "v1.0-20240301" + v2_5_20260210 = "v2.5-20260210" + + +class TripoRigType(str, Enum): + BIPED = "biped" + QUADRUPED = "quadruped" + HEXAPOD = "hexapod" + OCTOPOD = "octopod" + AVIAN = "avian" + SERPENTINE = "serpentine" + AQUATIC = "aquatic" + + class TripoAnimation(str, Enum): IDLE = "preset:idle" WALK = "preset:walk" @@ -84,6 +109,110 @@ class TripoAnimation(str, Enum): AQUATIC_MARCH = "preset:aquatic:march" +TRIPO_BIPED_ANIMATIONS = ( + "preset:biped:afraid", + "preset:biped:agree", + "preset:biped:angry_01", + "preset:biped:angry_02", + "preset:biped:angry_03", + "preset:biped:basketball_shot", + "preset:biped:bow", + "preset:biped:box_01", + "preset:biped:box_02", + "preset:biped:box_03", + "preset:biped:cast_a_spell", + "preset:biped:cheer", + "preset:biped:chop", + "preset:biped:clap", + "preset:biped:climb", + "preset:biped:complain_01", + "preset:biped:complain_02", + "preset:biped:cross_body_crunch", + "preset:biped:crossover_dribble", + "preset:biped:cry", + "preset:biped:dance_01", + "preset:biped:dance_02", + "preset:biped:dance_03", + "preset:biped:dance_04", + "preset:biped:dance_05", + "preset:biped:dance_06", + "preset:biped:defeat_02", + "preset:biped:defeat_03", + "preset:biped:depressed", + "preset:biped:dig", + "preset:biped:dive", + "preset:biped:dribble", + "preset:biped:fall", + "preset:biped:fire", + "preset:biped:flee_01", + "preset:biped:flee_02", + "preset:biped:flip", + "preset:biped:fold_arms", + "preset:biped:football_catch", + "preset:biped:football_save", + "preset:biped:football_pass", + "preset:biped:freaky", + "preset:biped:frightened", + "preset:biped:front_kick_01", + "preset:biped:front_kick_02", + "preset:biped:frustrated_01", + "preset:biped:frustrated_02", + "preset:biped:golf", + "preset:biped:greet_01", + "preset:biped:greet_02", + "preset:biped:greet_03", + "preset:biped:greet_04", + "preset:biped:heart_pose", + "preset:biped:hit_to_body_01", + "preset:biped:hit_to_body_02", + "preset:biped:hit_to_head", + "preset:biped:hit_to_side", + "preset:biped:hit_to_stomach", + "preset:biped:hug", + "preset:biped:hurt", + "preset:biped:idle", + "preset:biped:jump_down", + "preset:biped:jump", + "preset:biped:jump_rope_01", + "preset:biped:jump_rope_02", + "preset:biped:laugh_01", + "preset:biped:laugh_02", + "preset:biped:lift_heavy", + "preset:biped:look_around", + "preset:biped:make_a_call_01", + "preset:biped:make_a_call_02", + "preset:biped:pitch_baseball", + "preset:biped:play_mobile_game", + "preset:biped:play_video_game", + "preset:biped:run_upstairs", + "preset:biped:run", + "preset:biped:scared_01", + "preset:biped:scared_02", + "preset:biped:scratch", + "preset:biped:shoot", + "preset:biped:shovel", + "preset:biped:sing_01", + "preset:biped:sing_02", + "preset:biped:sing_03", + "preset:biped:sing_04", + "preset:biped:sit", + "preset:biped:slash", + "preset:biped:sob", + "preset:biped:standing_relax", + "preset:biped:surf", + "preset:biped:swagger", + "preset:biped:swim", + "preset:biped:turn", + "preset:biped:victory_celebration", + "preset:biped:volleyball", + "preset:biped:wait", + "preset:biped:walk", + "preset:biped:warm_up", + "preset:biped:wave_goodbye_01", + "preset:biped:wave_goodbye_02", +) + + class TripoConvertFormat(str, Enum): GLTF = "GLTF" USDZ = "USDZ" @@ -120,6 +249,14 @@ class TripoFbxPreset(str, Enum): BLENDER = "blender" MIXAMO = "mixamo" _3DSMAX = "3dsmax" + BAKE_SCALE = "bake_scale" + + +class TripoExportOrientation(str, Enum): + PLUS_X = "+x" + MINUS_X = "-x" + PLUS_Y = "+y" + MINUS_Y = "-y" class TripoFileTokenReference(BaseModel): @@ -153,7 +290,7 @@ class TripoFileReference(RootModel): 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: str | None = Field(None, description="The negative text prompt", max_length=1024) + negative_prompt: str | None = Field(None, description="The negative text prompt", max_length=255) model_version: TripoModelVersion | None = TripoModelVersion.v2_5_20250123 face_limit: int | None = Field(None, description="The number of faces to limit the generation to") texture: bool | None = Field(True, description="Whether to apply texture to the generated model") @@ -166,6 +303,7 @@ class TripoTextToModelRequest(BaseModel): style: TripoStyle | None = None auto_size: bool | None = Field(False, description="Whether to auto-size the model") quad: bool | None = Field(False, description="Whether to apply quad to the generated model") + smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology") class TripoImageToModelRequest(BaseModel): @@ -186,6 +324,7 @@ class TripoImageToModelRequest(BaseModel): auto_size: bool | None = Field(False, description="Whether to auto-size the model") orientation: TripoOrientation | None = TripoOrientation.DEFAULT quad: bool | None = Field(False, description="Whether to apply quad to the generated model") + smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology") class TripoMultiviewToModelRequest(BaseModel): @@ -204,15 +343,20 @@ class TripoMultiviewToModelRequest(BaseModel): auto_size: bool | None = Field(False, description="Whether to auto-size the model") orientation: TripoOrientation | None = Field(TripoOrientation.DEFAULT, description="The orientation for the model") quad: bool | None = Field(False, description="Whether to apply quad to the generated model") + smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology") class TripoTexturePrompt(BaseModel): text: str | None = Field(None, description="Text guidance for texture generation") + style_image: TripoFileReference | None = Field(None, description="Style reference, only together with text") + image: TripoFileReference | None = Field(None, description="Single reference image") + images: list[TripoFileReference] | None = Field(None, description="Exactly 4 reference images: front, left, back, right") 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") + model_version: TripoTextureModelVersion | None = Field(None, description="Texture model version") texture: bool | None = Field(True, description="Whether to apply texture to the model") pbr: bool | None = Field(True, description="Whether to apply PBR to the model") model_seed: int | None = Field(None, description="The seed for the model") @@ -226,11 +370,19 @@ class TripoTextureModelRequest(BaseModel): description="Optional guidance for texturing. Required in practice for imported models, " "which carry no source image to infer texture from.", ) + part_names: list[str] | None = Field(None, description="Parts of a segmented model to texture; all parts when omitted") + + +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") + model_version: TripoRigModelVersion | None = Field(None, description="Rigging model version") + rig_type: TripoRigType | None = Field(None, description="Skeleton type") out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format") spec: TripoSpec | None = Field(TripoSpec.TRIPO, description="The specification for rigging") @@ -238,9 +390,46 @@ class TripoAnimateRigRequest(BaseModel): 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") + animation: str = Field(..., description="The animation preset to apply") out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format") - bake_animation: bool | None = Field(True, description="Whether to bake the animation") + export_with_geometry: bool | None = Field(None, description="Whether to export geometry with the animation") + animate_in_place: bool | None = Field(None, description="Whether to play the animation in place") + + +class TripoMeshSegmentationRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.MESH_SEGMENTATION, description="Type of task") + original_model_task_id: str = Field(..., description="The task ID of the original model") + + +class TripoMeshCompletionRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.MESH_COMPLETION, description="Type of task") + original_model_task_id: str = Field(..., description="The task ID of a mesh segmentation task") + part_names: list[str] | None = Field(None, description="Parts to complete; all parts when omitted") + + +class TripoHighpolyToLowpolyRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.HIGHPOLY_TO_LOWPOLY, description="Type of task") + original_model_task_id: str = Field(..., description="The task ID of the original model") + face_limit: int | None = Field(None, description="Target face count; adaptive when omitted") + quad: bool | None = Field(None, description="Whether to output a quad mesh") + bake: bool | None = Field(None, description="Whether to bake textures onto the low-poly mesh") + part_names: list[str] | None = Field(None, description="Parts to retopologize; whole model when omitted") + + +class TripoGenerateMultiviewImageRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.GENERATE_MULTIVIEW_IMAGE, description="Type of task") + file: TripoFileReference = Field(..., description="The source image") + + +class TripoMultiviewEditPrompt(BaseModel): + view: str = Field(..., description="front, left, back or right") + prompt: str = Field(..., description="Edit instruction for the view", max_length=1024) + + +class TripoEditMultiviewImageRequest(BaseModel): + type: TripoTaskType = Field(TripoTaskType.EDIT_MULTIVIEW_IMAGE, description="Type of task") + original_task_id: str = Field(..., description="The task ID of the multiview images to edit") + prompts: list[TripoMultiviewEditPrompt] = Field(..., description="Per-view edit instructions") class TripoConvertModelRequest(BaseModel): @@ -262,7 +451,7 @@ class TripoConvertModelRequest(BaseModel): part_names: list[str] | None = Field(None, description="The names of the parts to include") fbx_preset: TripoFbxPreset | None = Field(None, description="The preset for the FBX export") export_vertex_colors: bool | None = Field(None, description="Whether to export the vertex colors") - export_orientation: TripoOrientation | None = Field(None, description="The orientation for the export") + export_orientation: TripoExportOrientation | None = Field(None, description="Forward axis of the exported model") animate_in_place: bool | None = Field(None, description="Whether to animate in place") @@ -326,6 +515,9 @@ class TripoTaskOutput(BaseModel): pbr_model: str | None = Field(None, description="URL to the PBR model") rendered_image: str | None = Field(None, description="URL to the rendered image") riggable: bool | None = Field(None, description="Whether the model is riggable") + rig_type: str | None = Field(None, description="Recommended rig type") + topology: str | None = Field(None, description="Legacy name of rig_type") + generate_multiview_image: dict[str, str] | None = Field(None, description="View name to image URL") class TripoTask(BaseModel): @@ -338,7 +530,7 @@ class TripoTask(BaseModel): create_time: int | None = Field(None, description="The creation time of the task") running_left_time: int | None = Field(None, description="The estimated time left for the task") queue_position: int | None = Field(None, description="The position in the queue") - consumed_credit: int | None = Field(None) + consumed_credit: float | None = Field(None) class TripoTaskResponse(BaseModel): diff --git a/comfy_api_nodes/nodes_tripo.py b/comfy_api_nodes/nodes_tripo.py index 3260af723..6b1bfec8e 100644 --- a/comfy_api_nodes/nodes_tripo.py +++ b/comfy_api_nodes/nodes_tripo.py @@ -1,38 +1,67 @@ +import json +from pathlib import Path +from urllib.parse import urlparse + from typing_extensions import override from comfy_api.latest import IO, ComfyExtension, Input, Types from comfy_api_nodes.apis.tripo import ( + TRIPO_BIPED_ANIMATIONS, + TripoAnimatePrerigcheckRequest, TripoAnimateRetargetRequest, + TripoAnimation, TripoAnimateRigRequest, TripoConvertModelRequest, + TripoEditMultiviewImageRequest, TripoFileEmptyReference, TripoFileReference, + TripoGenerateMultiviewImageRequest, + TripoHighpolyToLowpolyRequest, TripoImageToModelRequest, TripoImportModelRequest, + TripoMeshCompletionRequest, + TripoMeshSegmentationRequest, TripoModelVersion, + TripoMultiviewEditPrompt, TripoMultiviewToModelRequest, TripoOrientation, + TripoOutFormat, TripoP1ImageToModelRequest, TripoP1MultiviewToModelRequest, TripoP1TextToModelRequest, + TripoRigModelVersion, + TripoRigType, + TripoSpec, TripoStyle, TripoTaskResponse, TripoTaskStatus, TripoTaskType, TripoTextToModelRequest, TripoTextureModelRequest, + TripoTextureModelVersion, TripoTexturePrompt, TripoUrlReference, ) from comfy_api_nodes.util import ( ApiEndpoint, download_url_to_file_3d, + download_url_to_image_tensor, poll_op, sync_op, upload_3d_model_to_comfyapi, upload_images_to_comfyapi, ) +MULTIVIEW_KEYS = ("front_view_url", "left_view_url", "back_view_url", "right_view_url") +SEED_MAX = 2**31 - 1 +TEXTURE_SOURCE_TYPES_WITH_IMAGE = ("text_to_model", "image_to_model", "multiview_to_model", "texture_model") + + +FACE_LIMIT_TOOLTIP = ( + "Maximum face count. -1 lets Tripo pick adaptively (about 1.4M faces on v3.x standard, 2M on detailed). " + "Tripo clamps silently: v2.5 at 500,000, quad meshes at 150,000." +) + def get_model_url_from_response(response: TripoTaskResponse) -> str: if response.data is not None: @@ -42,18 +71,17 @@ def get_model_url_from_response(response: TripoTaskResponse) -> str: raise RuntimeError(f"Failed to get model url from response: {response}") -async def poll_until_finished( +async def poll_task( node_cls: type[IO.ComfyNode], response: TripoTaskResponse, average_duration: int | None = None, -) -> IO.NodeOutput: +) -> TripoTaskResponse: """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 + raise RuntimeError(f"Failed to create Tripo task: {response}") response_poll = await poll_op( node_cls, - poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{task_id}"), + poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{response.data.task_id}"), response_model=TripoTaskResponse, completed_statuses=[TripoTaskStatus.SUCCESS], failed_statuses=[ @@ -67,11 +95,84 @@ async def poll_until_finished( progress_extractor=lambda x: x.data.progress, estimated_duration=average_duration, ) - if response_poll.data.status == TripoTaskStatus.SUCCESS: - url = get_model_url_from_response(response_poll) - file_glb = await download_url_to_file_3d(url, "glb", task_id=task_id) - return IO.NodeOutput(f"{task_id}.glb", task_id, file_glb) - raise RuntimeError(f"Failed to generate mesh: {response_poll}") + if response_poll.data.status != TripoTaskStatus.SUCCESS: + raise RuntimeError(f"Tripo task failed: {response_poll}") + return response_poll + + +async def poll_until_finished( + node_cls: type[IO.ComfyNode], + response: TripoTaskResponse, + average_duration: int | None = None, +) -> tuple[str, Types.File3D]: + """Polls the Tripo API endpoint until the task reaches a terminal state, then downloads the model.""" + response_poll = await poll_task(node_cls, response, average_duration) + task_id = response_poll.data.task_id + url = get_model_url_from_response(response_poll) + file_format = Path(urlparse(url).path).suffix.lstrip(".").lower() or "glb" + return task_id, await download_url_to_file_3d(url, file_format, task_id=task_id) + + +async def check_riggable(node_cls: type[IO.ComfyNode], model_task_id: str) -> tuple[bool, str]: + response = await sync_op( + node_cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoAnimatePrerigcheckRequest(original_model_task_id=model_task_id), + ) + output = (await poll_task(node_cls, response, average_duration=5)).data.output + return bool(output.riggable), output.rig_type or output.topology or "" + + +async def upload_image_reference(node_cls: type[IO.ComfyNode], image: Input.Image) -> TripoFileReference: + url = (await upload_images_to_comfyapi(node_cls, image, max_images=1))[0] + return TripoFileReference(root=TripoUrlReference(url=url, type="jpeg")) + + +async def multiview_output(node_cls: type[IO.ComfyNode], response: TripoTaskResponse, with_task_id: bool) -> IO.NodeOutput: + response_poll = await poll_task(node_cls, response, average_duration=25) + views = response_poll.data.output.generate_multiview_image or {} + if any(not views.get(key) for key in MULTIVIEW_KEYS): + raise RuntimeError(f"Tripo returned incomplete multiview images: {response_poll}") + images = [await download_url_to_image_tensor(views[key], cls=node_cls) for key in MULTIVIEW_KEYS] + return IO.NodeOutput(response_poll.data.task_id, *images) if with_task_id else IO.NodeOutput(*images) + + +def part_names_from_glb(model: Types.File3D) -> list[str]: + data = model.get_bytes() + if data[:4] != b"glTF": + return [] + chunk_length = int.from_bytes(data[12:16], "little") + document = json.loads(data[20 : 20 + chunk_length]) + return [node["name"] for node in document.get("nodes", []) if node.get("name")] + + +def split_part_names(part_names: str) -> list[str] | None: + return list(dict.fromkeys(name.strip() for name in part_names.split(",") if name.strip())) or None + + +def glb_output(task_id: str, model: Types.File3D) -> IO.NodeOutput: + if model.format != "glb": + raise RuntimeError(f"Tripo returned a {model.format.upper()} file where GLB was expected") + return IO.NodeOutput(f"{task_id}.glb", task_id, model) + + +def check_smart_low_poly_face_limit(smart_low_poly: bool | None, face_limit: int | None, quad: bool | None) -> None: + if smart_low_poly and face_limit not in (None, -1) and not 500 <= face_limit <= (10000 if quad else 20000): + raise ValueError( + "With smart_low_poly, face_limit must be between 500 and 20,000 for triangles or 500 and 10,000 for quads." + ) + + +def glb_or_fbx_output(task_id: str, model: Types.File3D) -> IO.NodeOutput: + if model.format not in ("glb", "fbx"): + raise RuntimeError(f"Tripo returned a file of type {model.format.upper() or 'unknown'} where GLB or FBX was expected") + return IO.NodeOutput( + f"{task_id}.{model.format}", + task_id, + model if model.format == "glb" else None, + model if model.format == "fbx" else None, + ) class TripoTextToModelNode(IO.ComfyNode): @@ -87,25 +188,49 @@ class TripoTextToModelNode(IO.ComfyNode): category="partner/3d/Tripo", inputs=[ IO.String.Input("prompt", multiline=True), - IO.String.Input("negative_prompt", multiline=True, optional=True), + IO.String.Input("negative_prompt", multiline=True, optional=True, tooltip="Up to 255 characters."), IO.Combo.Input( - "model_version", options=TripoModelVersion, default=TripoModelVersion.v2_5_20250123, optional=True + "model_version", options=TripoModelVersion, default=TripoModelVersion.v3_1_20260211, optional=True ), - IO.Combo.Input("style", options=TripoStyle, default="None", optional=True), - IO.Boolean.Input("texture", default=True, optional=True), - IO.Boolean.Input("pbr", default=True, optional=True), - IO.Int.Input("image_seed", default=42, optional=True, advanced=True), - IO.Int.Input("model_seed", default=42, optional=True, advanced=True), - IO.Int.Input("texture_seed", default=42, optional=True, advanced=True), + IO.Combo.Input( + "style", + options=TripoStyle, + default="None", + optional=True, + tooltip="No longer supported by Tripo and ignored. Kept for older workflows.", + ), + IO.Boolean.Input( + "texture", + default=True, + optional=True, + tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", + ), + IO.Int.Input("image_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), + IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), + IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Combo.Input( "texture_quality", default="standard", - options=["standard", "detailed"], + options=["standard", "detailed", "extreme"], optional=True, advanced=True, + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", + ), + IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), + IO.Boolean.Input( + "quad", + default=False, + optional=True, + advanced=True, + tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " + "arrives on the FBX output and the GLB output stays empty.", ), - IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True), - IO.Boolean.Input("quad", default=False, optional=True, advanced=True), IO.Combo.Input( "geometry_quality", default="standard", @@ -113,11 +238,28 @@ class TripoTextToModelNode(IO.ComfyNode): optional=True, advanced=True, ), + IO.Boolean.Input( + "smart_low_poly", + default=False, + optional=True, + advanced=True, + tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " + "Best for simple subjects; complex ones may fail.", + ), + IO.Boolean.Input( + "auto_size", + default=True, + optional=True, + advanced=True, + tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " + "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -131,26 +273,24 @@ class TripoTextToModelNode(IO.ComfyNode): widgets=[ "model_version", "texture", - "pbr", "quad", + "smart_low_poly", "texture_quality", "geometry_quality", ], ), expr=""" ( - $isV14 := $contains(widgets.model_version,"v1.4"); $isV3OrLater := $contains(widgets.model_version,"v3."); - $withTexture := widgets.texture or widgets.pbr; - $isHdTexture := (widgets.texture_quality = "detailed"); - $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $credits := $isV14 ? 20 : ( - ($withTexture ? 20 : 10) + $tq := widgets.texture_quality; + $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; + $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; + $credits := (widgets.texture ? 20 : 10) + (widgets.quad ? 5 : 0) - + ($isHdTexture ? 10 : 0) - + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) - ); - {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} + + (widgets.smart_low_poly ? 10 : 0) + + $textureAddon + + $geometryAddon; + {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} ) """, ), @@ -172,10 +312,12 @@ class TripoTextToModelNode(IO.ComfyNode): geometry_quality: str | None = None, face_limit: int | None = None, quad: bool | None = None, + smart_low_poly: bool | None = None, + auto_size: bool = True, ) -> IO.NodeOutput: - style_enum = None if style == "None" else style - if not prompt: + if not prompt.strip(): raise RuntimeError("Prompt is required") + check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) response = await sync_op( cls, endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), @@ -185,20 +327,20 @@ class TripoTextToModelNode(IO.ComfyNode): prompt=prompt, negative_prompt=negative_prompt if negative_prompt else None, model_version=model_version, - style=style_enum, texture=texture, - pbr=pbr, + pbr=False if texture is False else pbr, image_seed=image_seed, model_seed=model_seed, texture_seed=texture_seed, texture_quality=texture_quality, face_limit=face_limit if face_limit != -1 else None, geometry_quality=geometry_quality, - auto_size=True, + auto_size=auto_size, quad=quad, + smart_low_poly=smart_low_poly, ), ) - return await poll_until_finished(cls, response, average_duration=80) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoImageToModelNode(IO.ComfyNode): @@ -220,10 +362,26 @@ class TripoImageToModelNode(IO.ComfyNode): tooltip="The model version to use for generation", optional=True, ), - IO.Combo.Input("style", options=TripoStyle, default="None", optional=True), - IO.Boolean.Input("texture", default=True, optional=True), - IO.Boolean.Input("pbr", default=True, optional=True), - IO.Int.Input("model_seed", default=42, optional=True, advanced=True), + IO.Combo.Input( + "style", + options=TripoStyle, + default="None", + optional=True, + tooltip="No longer supported by Tripo and ignored. Kept for older workflows.", + ), + IO.Boolean.Input( + "texture", + default=True, + optional=True, + tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", + ), + IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Combo.Input( "orientation", options=TripoOrientation, @@ -231,13 +389,14 @@ class TripoImageToModelNode(IO.ComfyNode): optional=True, advanced=True, ), - IO.Int.Input("texture_seed", default=42, optional=True, advanced=True), + IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Combo.Input( "texture_quality", default="standard", - options=["standard", "detailed"], + options=["standard", "detailed", "extreme"], optional=True, advanced=True, + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", ), IO.Combo.Input( "texture_alignment", @@ -246,8 +405,15 @@ class TripoImageToModelNode(IO.ComfyNode): optional=True, advanced=True, ), - IO.Int.Input("face_limit", default=-1, min=-1, max=500000, optional=True, advanced=True), - IO.Boolean.Input("quad", default=False, optional=True, advanced=True), + IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), + IO.Boolean.Input( + "quad", + default=False, + optional=True, + advanced=True, + tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " + "arrives on the FBX output and the GLB output stays empty.", + ), IO.Combo.Input( "geometry_quality", default="standard", @@ -255,11 +421,28 @@ class TripoImageToModelNode(IO.ComfyNode): optional=True, advanced=True, ), + IO.Boolean.Input( + "smart_low_poly", + default=False, + optional=True, + advanced=True, + tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " + "Best for simple subjects; complex ones may fail.", + ), + IO.Boolean.Input( + "auto_size", + default=True, + optional=True, + advanced=True, + tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " + "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -273,26 +456,24 @@ class TripoImageToModelNode(IO.ComfyNode): widgets=[ "model_version", "texture", - "pbr", "quad", + "smart_low_poly", "texture_quality", "geometry_quality", ], ), expr=""" ( - $isV14 := $contains(widgets.model_version,"v1.4"); $isV3OrLater := $contains(widgets.model_version,"v3."); - $withTexture := widgets.texture or widgets.pbr; - $isHdTexture := (widgets.texture_quality = "detailed"); - $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $credits := $isV14 ? 30 : ( - ($withTexture ? 30 : 20) + $tq := widgets.texture_quality; + $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; + $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; + $credits := (widgets.texture ? 30 : 20) + (widgets.quad ? 5 : 0) - + ($isHdTexture ? 10 : 0) - + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) - ); - {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} + + (widgets.smart_low_poly ? 10 : 0) + + $textureAddon + + $geometryAddon; + {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} ) """, ), @@ -314,10 +495,12 @@ class TripoImageToModelNode(IO.ComfyNode): texture_alignment: str | None = None, face_limit: int | None = None, quad: bool | None = None, + smart_low_poly: bool | None = None, + auto_size: bool = True, ) -> IO.NodeOutput: - style_enum = None if style == "None" else style if image is None: raise RuntimeError("Image is required") + check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) tripo_file = TripoFileReference( root=TripoUrlReference( url=(await upload_images_to_comfyapi(cls, image, max_images=1))[0], @@ -332,9 +515,8 @@ class TripoImageToModelNode(IO.ComfyNode): type=TripoTaskType.IMAGE_TO_MODEL, file=tripo_file, model_version=model_version, - style=style_enum, texture=texture, - pbr=pbr, + pbr=False if texture is False else pbr, model_seed=model_seed, orientation=orientation, geometry_quality=geometry_quality, @@ -342,11 +524,12 @@ class TripoImageToModelNode(IO.ComfyNode): texture_seed=texture_seed, texture_quality=texture_quality, face_limit=face_limit if face_limit != -1 else None, - auto_size=True, + auto_size=auto_size, quad=quad, + smart_low_poly=smart_low_poly, ), ) - return await poll_until_finished(cls, response, average_duration=80) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoMultiviewToModelNode(IO.ComfyNode): @@ -378,16 +561,27 @@ class TripoMultiviewToModelNode(IO.ComfyNode): optional=True, advanced=True, ), - IO.Boolean.Input("texture", default=True, optional=True), - IO.Boolean.Input("pbr", default=True, optional=True), - IO.Int.Input("model_seed", default=42, optional=True, advanced=True), - IO.Int.Input("texture_seed", default=42, optional=True, advanced=True), + IO.Boolean.Input( + "texture", + default=True, + optional=True, + tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", + ), + IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), + IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Combo.Input( "texture_quality", default="standard", - options=["standard", "detailed"], + options=["standard", "detailed", "extreme"], optional=True, advanced=True, + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", ), IO.Combo.Input( "texture_alignment", @@ -396,13 +590,14 @@ class TripoMultiviewToModelNode(IO.ComfyNode): optional=True, advanced=True, ), - IO.Int.Input("face_limit", default=-1, min=-1, max=500000, optional=True, advanced=True), + IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), IO.Boolean.Input( "quad", default=False, optional=True, advanced=True, - tooltip="This parameter is deprecated and does nothing.", + tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " + "arrives on the FBX output and the GLB output stays empty.", ), IO.Combo.Input( "geometry_quality", @@ -411,11 +606,28 @@ class TripoMultiviewToModelNode(IO.ComfyNode): optional=True, advanced=True, ), + IO.Boolean.Input( + "smart_low_poly", + default=False, + optional=True, + advanced=True, + tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " + "Best for simple subjects; complex ones may fail.", + ), + IO.Boolean.Input( + "auto_size", + default=False, + optional=True, + advanced=True, + tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " + "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -429,24 +641,24 @@ class TripoMultiviewToModelNode(IO.ComfyNode): widgets=[ "model_version", "texture", - "pbr", + "quad", + "smart_low_poly", "texture_quality", "geometry_quality", ], ), expr=""" ( - $isV14 := $contains(widgets.model_version,"v1.4"); $isV3OrLater := $contains(widgets.model_version,"v3."); - $withTexture := widgets.texture or widgets.pbr; - $isHdTexture := (widgets.texture_quality = "detailed"); - $isDetailedGeometry := (widgets.geometry_quality = "detailed"); - $credits := $isV14 ? 30 : ( - ($withTexture ? 30 : 20) - + ($isHdTexture ? 10 : 0) - + (($isDetailedGeometry and $isV3OrLater) ? 20 : 0) - ); - {"type":"usd","usd": $round($credits * 0.01, 2), "format": {"approximate": true}} + $tq := widgets.texture_quality; + $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; + $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; + $credits := (widgets.texture ? 30 : 20) + + (widgets.quad ? 5 : 0) + + (widgets.smart_low_poly ? 10 : 0) + + $textureAddon + + $geometryAddon; + {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} ) """, ), @@ -470,6 +682,8 @@ class TripoMultiviewToModelNode(IO.ComfyNode): texture_alignment: str | None = None, face_limit: int | None = None, quad: bool | None = None, + smart_low_poly: bool | None = None, + auto_size: bool = False, ) -> IO.NodeOutput: if image is None: raise RuntimeError("front image for multiview is required") @@ -477,6 +691,7 @@ class TripoMultiviewToModelNode(IO.ComfyNode): image_dict = {"image": image, "image_left": image_left, "image_back": image_back, "image_right": image_right} if image_left is None and image_back is None and image_right is None: raise RuntimeError("At least one of left, back, or right image must be provided for multiview") + check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) for image_name in ["image", "image_left", "image_back", "image_right"]: image_ = image_dict[image_name] if image_ is not None: @@ -499,17 +714,123 @@ class TripoMultiviewToModelNode(IO.ComfyNode): model_version=model_version, orientation=orientation, texture=texture, - pbr=pbr, + pbr=False if texture is False else pbr, model_seed=model_seed, texture_seed=texture_seed, texture_quality=texture_quality, geometry_quality=geometry_quality, texture_alignment=texture_alignment, face_limit=face_limit if face_limit != -1 else None, - quad=None, + auto_size=auto_size, + quad=quad, + smart_low_poly=smart_low_poly, ), ) - return await poll_until_finished(cls, response, average_duration=80) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) + + +class TripoImageToMultiviewNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoImageToMultiviewNode", + display_name="Tripo: Image to Multiview", + category="partner/3d/Tripo", + description="Generates front, left, back and right views of the subject. Feed them into " + "Tripo: Multiview to Model, or refine them first with Tripo: Edit Multiview.", + inputs=[IO.Image.Input("image")], + outputs=[ + IO.Custom("MULTIVIEW_TASK_ID").Output(display_name="multiview task_id"), + IO.Image.Output(display_name="front"), + IO.Image.Output(display_name="left"), + IO.Image.Output(display_name="back"), + IO.Image.Output(display_name="right"), + ], + 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.1, "format": {"approximate": true}}""", + ), + ) + + @classmethod + async def execute(cls, image: Input.Image) -> IO.NodeOutput: + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoGenerateMultiviewImageRequest(file=await upload_image_reference(cls, image)), + ) + return await multiview_output(cls, response, with_task_id=True) + + +class TripoEditMultiviewNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoEditMultiviewNode", + display_name="Tripo: Edit Multiview", + category="partner/3d/Tripo", + description="Edits the views of a Tripo: Image to Multiview result with per-view text instructions. " + "Views without an instruction stay unchanged. Feed the images into Tripo: Multiview to Model; " + "an edited set cannot be edited again.", + inputs=[ + IO.Custom("MULTIVIEW_TASK_ID").Input("multiview_task_id"), + IO.String.Input("front_prompt", default="", multiline=True, optional=True), + IO.String.Input("left_prompt", default="", multiline=True, optional=True), + IO.String.Input("back_prompt", default="", multiline=True, optional=True), + IO.String.Input("right_prompt", default="", multiline=True, optional=True), + ], + outputs=[ + IO.Image.Output(display_name="front"), + IO.Image.Output(display_name="left"), + IO.Image.Output(display_name="back"), + IO.Image.Output(display_name="right"), + ], + 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=["front_prompt", "left_prompt", "back_prompt", "right_prompt"]), + expr=""" + ( + $prompts := [widgets.front_prompt, widgets.left_prompt, widgets.back_prompt, widgets.right_prompt]; + $edited := $count($filter($prompts, function($p) { $length($trim($p)) > 0 })); + {"type":"usd","usd": $edited * 0.05, "format": {"approximate": true}} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + multiview_task_id, + front_prompt: str = "", + left_prompt: str = "", + back_prompt: str = "", + right_prompt: str = "", + ) -> IO.NodeOutput: + views = zip(("front", "left", "back", "right"), (front_prompt, left_prompt, back_prompt, right_prompt)) + prompts = [TripoMultiviewEditPrompt(view=view, prompt=prompt.strip()) for view, prompt in views if prompt.strip()] + if not prompts: + raise ValueError("Provide an edit instruction for at least one view.") + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoEditMultiviewImageRequest(original_task_id=multiview_task_id, prompts=prompts), + ) + return await multiview_output(cls, response, with_task_id=False) class TripoTextureNode(IO.ComfyNode): @@ -521,16 +842,27 @@ class TripoTextureNode(IO.ComfyNode): display_name="Tripo: Texture model", category="partner/3d/Tripo", inputs=[ - IO.Custom("MODEL_TASK_ID").Input("model_task_id"), - IO.Boolean.Input("texture", default=True, optional=True), - IO.Boolean.Input("pbr", default=True, optional=True), - IO.Int.Input("texture_seed", default=42, optional=True, advanced=True), + IO.Custom("MODEL_TASK_ID,SEGMENT_TASK_ID").Input("model_task_id"), + IO.Boolean.Input( + "texture", + default=True, + optional=True, + tooltip="Ignored: this node always generates textures. Kept for older workflows.", + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal); off gives a plain color texture.", + ), + IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Combo.Input( "texture_quality", default="standard", - options=["standard", "detailed"], + options=["standard", "detailed", "extreme"], optional=True, advanced=True, + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", ), IO.Combo.Input( "texture_alignment", @@ -545,13 +877,58 @@ class TripoTextureNode(IO.ComfyNode): multiline=True, optional=True, tooltip="Optional text guidance for texturing. Required in practice for imported " - "models (Tripo: Import Model), which carry no source image to infer colors from.", + "models (Tripo: Import Model), which carry no source image to infer colors from. " + "Cannot be combined with reference images.", + ), + IO.Combo.Input( + "model_version", + options=TripoTextureModelVersion, + default=TripoTextureModelVersion.v3_0_20250812, + optional=True, + tooltip="Texture model: v3.0 for meshes generated with v3.x, v2.5 for meshes generated with v2.5.", + ), + IO.Image.Input( + "style_image", + optional=True, + tooltip="Reference image for the artistic style of the textures. Only used together with texture_prompt.", + ), + IO.DynamicCombo.Input( + "reference", + options=[ + IO.DynamicCombo.Option("none", []), + IO.DynamicCombo.Option( + "image", + [IO.Image.Input("reference_image", tooltip="Single reference image the textures should follow.")], + ), + IO.DynamicCombo.Option( + "multiview", + [ + IO.Image.Input("image_front", tooltip="Front view (0°)."), + IO.Image.Input("image_left", tooltip="Left view (90°)."), + IO.Image.Input("image_back", tooltip="Back view (180°)."), + IO.Image.Input("image_right", tooltip="Right view (270°)."), + ], + ), + ], + optional=True, + tooltip="Reference images guiding the textures. Cannot be combined with texture_prompt or style_image.", + ), + IO.String.Input( + "part_names", + default="", + optional=True, + advanced=True, + tooltip="Comma-separated part names from Tripo: Segment Model to texture. Empty textures every part.", ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when the source is a quad mesh or an FBX import."), + IO.File3DFBX.Output( + display_name="FBX", + tooltip="Tripo returns FBX for quad meshes and FBX imports; empty otherwise.", + ), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -565,7 +942,7 @@ class TripoTextureNode(IO.ComfyNode): expr=""" ( $tq := widgets.texture_quality; - {"type":"usd","usd": ($contains($tq,"detailed") ? 0.2 : 0.1), "format": {"approximate": true}} + {"type":"usd","usd": ($tq = "extreme" ? 0.3 : ($tq = "detailed" ? 0.2 : 0.1)), "format": {"approximate": true}} ) """, ), @@ -581,22 +958,58 @@ class TripoTextureNode(IO.ComfyNode): texture_quality: str | None = None, texture_alignment: str | None = None, texture_prompt: str = "", + model_version: str | None = None, + style_image: Input.Image | None = None, + reference: dict | None = None, + part_names: str = "", ) -> IO.NodeOutput: + text = texture_prompt.strip() + mode = reference["reference"] if reference else "none" + if mode != "none" and (text or style_image is not None): + raise ValueError("Reference images cannot be combined with texture_prompt or style_image.") + if style_image is not None and not text: + raise ValueError("style_image requires a texture_prompt.") + if not text: + source = await sync_op( + cls, + endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{model_task_id}"), + response_model=TripoTaskResponse, + ) + if source.data.type not in TEXTURE_SOURCE_TYPES_WITH_IMAGE: + raise ValueError( + "This model has no source image to texture from (imported, segmented, completed or retopologized). " + "Give a texture_prompt; Tripo accepts reference images only for models it generated itself." + ) + if mode == "image": + prompt = TripoTexturePrompt(image=await upload_image_reference(cls, reference["reference_image"])) + elif mode == "multiview": + views = [reference[k] for k in ("image_front", "image_left", "image_back", "image_right")] + urls = await upload_images_to_comfyapi(cls, views, max_images=4) + prompt = TripoTexturePrompt(images=[TripoFileReference(root=TripoUrlReference(url=u, type="jpeg")) for u in urls]) + elif text: + prompt = TripoTexturePrompt( + text=text, + style_image=await upload_image_reference(cls, style_image) if style_image is not None else None, + ) + else: + prompt = None response = await sync_op( cls, endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), response_model=TripoTaskResponse, data=TripoTextureModelRequest( original_model_task_id=model_task_id, - texture=texture, + model_version=model_version, + texture=True, pbr=pbr, texture_seed=texture_seed, texture_quality=texture_quality, texture_alignment=texture_alignment, - texture_prompt=TripoTexturePrompt(text=texture_prompt.strip()) if texture_prompt.strip() else None, + texture_prompt=prompt, + part_names=split_part_names(part_names), ), ) - return await poll_until_finished(cls, response, average_duration=80) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoRigNode(IO.ComfyNode): @@ -607,11 +1020,44 @@ class TripoRigNode(IO.ComfyNode): node_id="TripoRigNode", display_name="Tripo: Rig model", category="partner/3d/Tripo", - inputs=[IO.Custom("MODEL_TASK_ID").Input("original_model_task_id")], + inputs=[ + IO.Custom("MODEL_TASK_ID").Input("original_model_task_id"), + IO.Combo.Input( + "model_version", + options=TripoRigModelVersion, + default=TripoRigModelVersion.v1_0_20240301, + optional=True, + tooltip="v1.0: humanoid (biped) characters only, 90+ animation presets. " + "v2.5: non-humanoid creatures (quadruped, hexapod, octopod, avian, serpentine, aquatic).", + ), + IO.Combo.Input( + "rig_type", + options=["auto", *[t.value for t in TripoRigType]], + default="auto", + optional=True, + tooltip="Skeleton type. 'auto' runs Tripo's free rig check first and uses the recommended type.", + ), + IO.Combo.Input( + "spec", + options=TripoSpec, + default=TripoSpec.TRIPO, + optional=True, + tooltip="Bone naming: Tripo native or Mixamo-compatible. Tripo cannot retarget its animation presets " + "onto a v1.0 rig made with the mixamo spec; use tripo for Tripo: Retarget rigged model.", + ), + IO.Combo.Input( + "out_format", + options=TripoOutFormat, + default=TripoOutFormat.GLB, + optional=True, + tooltip="Output file format; the result arrives on the matching output.", + ), + ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("RIG_TASK_ID").Output(display_name="rig task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Populated when out_format is glb."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Populated when out_format is fbx."), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -626,14 +1072,33 @@ class TripoRigNode(IO.ComfyNode): ) @classmethod - async def execute(cls, original_model_task_id) -> IO.NodeOutput: + async def execute( + cls, + original_model_task_id, + model_version: str = "v1.0-20240301", + rig_type: str = "auto", + spec: str = "tripo", + out_format: str = "glb", + ) -> IO.NodeOutput: + if rig_type == "auto": + riggable, rig_type = await check_riggable(cls, original_model_task_id) + if not riggable: + raise ValueError("Tripo reports that this model cannot be rigged.") + if rig_type != "biped" and model_version == "v1.0-20240301": + raise ValueError(f"Rig model v1.0-20240301 only supports biped skeletons; use v2.5-20260210 for {rig_type}.") response = await sync_op( cls, endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), response_model=TripoTaskResponse, - data=TripoAnimateRigRequest(original_model_task_id=original_model_task_id, out_format="glb", spec="tripo"), + data=TripoAnimateRigRequest( + original_model_task_id=original_model_task_id, + model_version=model_version, + rig_type=rig_type, + out_format=out_format, + spec=spec, + ), ) - return await poll_until_finished(cls, response, average_duration=180) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=180)) class TripoRetargetNode(IO.ComfyNode): @@ -648,30 +1113,37 @@ class TripoRetargetNode(IO.ComfyNode): IO.Custom("RIG_TASK_ID").Input("original_model_task_id"), IO.Combo.Input( "animation", - options=[ - "preset:idle", - "preset:walk", - "preset:run", - "preset:dive", - "preset:climb", - "preset:jump", - "preset:slash", - "preset:shoot", - "preset:hurt", - "preset:fall", - "preset:turn", - "preset:quadruped:walk", - "preset:hexapod:walk", - "preset:octopod:walk", - "preset:serpentine:march", - "preset:aquatic:march", - ], + options=[*[a.value for a in TripoAnimation], *TRIPO_BIPED_ANIMATIONS], + tooltip="preset:* animations work with both rig models; " + "preset:biped:* animations require a rig made with model v1.0-20240301.", + ), + IO.Combo.Input( + "out_format", + options=TripoOutFormat, + default=TripoOutFormat.GLB, + optional=True, + tooltip="Output file format; the result arrives on the matching output.", + ), + IO.Boolean.Input( + "export_with_geometry", + default=True, + optional=True, + advanced=True, + tooltip="Include the mesh in the export; off exports the animated skeleton only.", + ), + IO.Boolean.Input( + "animate_in_place", + default=False, + optional=True, + advanced=True, + tooltip="Play the animation in place, without root displacement.", ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only IO.Custom("RETARGET_TASK_ID").Output(display_name="retarget task_id"), - IO.File3DGLB.Output(display_name="GLB"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Populated when out_format is glb."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Populated when out_format is fbx."), ], hidden=[ IO.Hidden.auth_token_comfy_org, @@ -686,7 +1158,22 @@ class TripoRetargetNode(IO.ComfyNode): ) @classmethod - async def execute(cls, original_model_task_id, animation: str) -> IO.NodeOutput: + async def execute( + cls, + original_model_task_id, + animation: str, + out_format: str = "glb", + export_with_geometry: bool = True, + animate_in_place: bool = False, + ) -> IO.NodeOutput: + rig = await sync_op( + cls, + endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{original_model_task_id}"), + response_model=TripoTaskResponse, + ) + rig_input = rig.data.input or {} + if rig_input.get("spec") == "mixamo" and str(rig_input.get("model_version", "")).startswith("v1.0"): + raise ValueError("Tripo cannot retarget animation presets onto a v1.0 rig made with the mixamo spec.") response = await sync_op( cls, endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), @@ -694,11 +1181,220 @@ class TripoRetargetNode(IO.ComfyNode): data=TripoAnimateRetargetRequest( original_model_task_id=original_model_task_id, animation=animation, - out_format="glb", - bake_animation=True, + out_format=out_format, + export_with_geometry=export_with_geometry, + animate_in_place=animate_in_place, ), ) - return await poll_until_finished(cls, response, average_duration=30) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=30)) + + +class TripoRigCheckNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoRigCheckNode", + display_name="Tripo: Rig Check", + category="partner/3d/Tripo", + description="Checks whether a model can be rigged and which skeleton type Tripo recommends.", + inputs=[IO.Custom("MODEL_TASK_ID").Input("model_task_id")], + outputs=[ + IO.Boolean.Output(display_name="riggable"), + IO.String.Output( + display_name="rig_type", + tooltip="Recommended skeleton: biped, quadruped, hexapod, octopod, avian, serpentine or aquatic; " + "'others' when the model is not riggable.", + ), + ], + 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":"text","text":"Free"}""", + ), + ) + + @classmethod + async def execute(cls, model_task_id) -> IO.NodeOutput: + riggable, rig_type = await check_riggable(cls, model_task_id) + return IO.NodeOutput(riggable, rig_type) + + +class TripoSegmentNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoSegmentNode", + display_name="Tripo: Segment Model", + category="partner/3d/Tripo", + description="Splits a model into parts. The part names feed Tripo: Complete Mesh Parts, " + "Tripo: Retopology and Tripo: Convert model.", + inputs=[IO.Custom("MODEL_TASK_ID").Input("model_task_id")], + outputs=[ + IO.String.Output(display_name="model_file"), # for backward compatibility only + IO.Custom("SEGMENT_TASK_ID").Output(display_name="segment task_id"), + IO.File3DGLB.Output(display_name="GLB"), + IO.String.Output(display_name="part_names", tooltip="Comma-separated names of the parts."), + ], + 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, "format": {"approximate": true}}""", + ), + ) + + @classmethod + async def execute(cls, model_task_id) -> IO.NodeOutput: + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoMeshSegmentationRequest(original_model_task_id=model_task_id), + ) + task_id, model = await poll_until_finished(cls, response, average_duration=160) + return IO.NodeOutput(f"{task_id}.glb", task_id, model, ",".join(part_names_from_glb(model))) + + +class TripoMeshCompleteNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoMeshCompleteNode", + display_name="Tripo: Complete Mesh Parts", + category="partner/3d/Tripo", + description="Completes the parts of a segmented model and repairs missing regions.", + inputs=[ + IO.Custom("SEGMENT_TASK_ID").Input("segment_task_id"), + IO.String.Input( + "part_names", + default="", + optional=True, + tooltip="Comma-separated part names to complete. Empty completes every part.", + ), + ], + outputs=[ + IO.String.Output(display_name="model_file"), # for backward compatibility only + IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), + 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, + is_output_node=True, + price_badge=IO.PriceBadge( + expr="""{"type":"usd","usd":0.5, "format": {"approximate": true}}""", + ), + ) + + @classmethod + async def execute(cls, segment_task_id, part_names: str = "") -> IO.NodeOutput: + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoMeshCompletionRequest( + original_model_task_id=segment_task_id, + part_names=split_part_names(part_names), + ), + ) + return glb_output(*await poll_until_finished(cls, response, average_duration=240)) + + +class TripoRetopologyNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoRetopologyNode", + display_name="Tripo: Retopology", + category="partner/3d/Tripo", + description="Rebuilds a low-poly mesh with clean topology from a high-poly model.", + inputs=[ + IO.Custom("MODEL_TASK_ID,SEGMENT_TASK_ID").Input("model_task_id"), + IO.Int.Input( + "face_limit", + default=-1, + min=-1, + max=20000, + tooltip="Target face count: 500-20,000 triangles or 500-10,000 quads. -1 lets Tripo choose.", + ), + IO.Boolean.Input( + "quad", + default=False, + tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " + "arrives on the FBX output and the GLB output stays empty.", + ), + IO.Boolean.Input( + "bake", + default=True, + optional=True, + advanced=True, + tooltip="Bake the source textures onto the low-poly mesh.", + ), + IO.String.Input( + "part_names", + default="", + optional=True, + advanced=True, + tooltip="Comma-separated part names from Tripo: Segment Model. Empty processes the whole model.", + ), + ], + outputs=[ + IO.String.Output(display_name="model_file"), # for backward compatibility only + IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), + IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), + IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), + ], + 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.3, "format": {"approximate": true}}""", + ), + ) + + @classmethod + async def execute( + cls, + model_task_id, + face_limit: int = -1, + quad: bool = False, + bake: bool = True, + part_names: str = "", + ) -> IO.NodeOutput: + if face_limit != -1 and not 500 <= face_limit <= (10000 if quad else 20000): + raise ValueError("face_limit must be between 500 and 20,000 for triangles or 500 and 10,000 for quads.") + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + response_model=TripoTaskResponse, + data=TripoHighpolyToLowpolyRequest( + original_model_task_id=model_task_id, + face_limit=face_limit if face_limit != -1 else None, + quad=quad, + bake=bake, + part_names=split_part_names(part_names), + ), + ) + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=200)) class TripoConversionNode(IO.ComfyNode): @@ -710,7 +1406,7 @@ class TripoConversionNode(IO.ComfyNode): display_name="Tripo: Convert model", category="partner/3d/Tripo", inputs=[ - IO.Custom("MODEL_TASK_ID,RIG_TASK_ID,RETARGET_TASK_ID").Input("original_model_task_id"), + IO.Custom("MODEL_TASK_ID,RIG_TASK_ID,RETARGET_TASK_ID,SEGMENT_TASK_ID").Input("original_model_task_id"), IO.Combo.Input("format", options=["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"]), IO.Boolean.Input("quad", default=False, optional=True, advanced=True), IO.Int.Input( @@ -725,7 +1421,7 @@ class TripoConversionNode(IO.ComfyNode): "texture_size", default=4096, min=128, - max=4096, + max=8192, optional=True, advanced=True, ), @@ -740,42 +1436,63 @@ class TripoConversionNode(IO.ComfyNode): IO.Boolean.Input("flatten_bottom", default=False, optional=True, advanced=True), IO.Float.Input( "flatten_bottom_threshold", - default=0.0, - min=0.0, + default=0.01, + min=0.01, max=1.0, optional=True, advanced=True, + tooltip="Flattening depth used with flatten_bottom.", ), IO.Boolean.Input("pivot_to_center_bottom", default=False, optional=True, advanced=True), IO.Float.Input( "scale_factor", default=1.0, - min=0.0, + min=0.01, optional=True, advanced=True, ), - IO.Boolean.Input("with_animation", default=False, optional=True, advanced=True), + IO.Boolean.Input( + "with_animation", + default=True, + optional=True, + advanced=True, + tooltip="Keep the skeleton and animation of rigged or retargeted models.", + ), IO.Boolean.Input("pack_uv", default=False, optional=True, advanced=True), - IO.Boolean.Input("bake", default=False, optional=True, advanced=True), + IO.Boolean.Input( + "bake", + default=True, + optional=True, + advanced=True, + tooltip="Bake advanced materials into the base textures for broader compatibility.", + ), IO.String.Input("part_names", default="", optional=True, advanced=True), # comma-separated list IO.Combo.Input( "fbx_preset", - options=["blender", "mixamo", "3dsmax"], + options=["blender", "mixamo", "3dsmax", "bake_scale"], default="blender", optional=True, advanced=True, + tooltip="FBX compatibility preset. bake_scale bakes the scale transform into the geometry.", ), IO.Boolean.Input("export_vertex_colors", default=False, optional=True, advanced=True), IO.Combo.Input( "export_orientation", - options=["align_image", "default"], + options=["default", "+x", "-x", "+y", "-y"], default="default", optional=True, advanced=True, + tooltip="Forward axis of the exported model. default keeps Tripo's +x.", ), IO.Boolean.Input("animate_in_place", default=False, optional=True, advanced=True), ], - outputs=[], + outputs=[ + IO.File3DAny.Output( + display_name="model_3d", + tooltip="Converted model in the requested format. OBJ is delivered by Tripo as a ZIP archive " + "(mesh, material and textures).", + ), + ], hidden=[ IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, @@ -791,7 +1508,6 @@ class TripoConversionNode(IO.ComfyNode): "texture_size", "texture_format", "flatten_bottom", - "flatten_bottom_threshold", "pivot_to_center_bottom", "scale_factor", ], @@ -800,7 +1516,6 @@ class TripoConversionNode(IO.ComfyNode): ( $face := (widgets.face_limit != null) ? widgets.face_limit : -1; $texSize := (widgets.texture_size != null) ? widgets.texture_size : 4096; - $flatThresh := (widgets.flatten_bottom_threshold != null) ? widgets.flatten_bottom_threshold : 0; $scale := (widgets.scale_factor != null) ? widgets.scale_factor : 1; $texFmt := (widgets.texture_format != "" ? widgets.texture_format : "jpeg"); $advanced := @@ -809,7 +1524,6 @@ class TripoConversionNode(IO.ComfyNode): widgets.pivot_to_center_bottom or ($face != -1) or ($texSize != 4096) or - ($flatThresh != 0) or ($scale != 1) or ($texFmt != "jpeg"); {"type":"usd","usd": ($advanced ? 0.1 : 0.05), "format": {"approximate": true}} @@ -818,14 +1532,6 @@ class TripoConversionNode(IO.ComfyNode): ), ) - @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 - @classmethod async def execute( cls, @@ -853,9 +1559,7 @@ class TripoConversionNode(IO.ComfyNode): raise RuntimeError("original_model_task_id is required") # Parse part_names from comma-separated string to list - part_names_list = None - if part_names and part_names.strip(): - part_names_list = [name.strip() for name in part_names.split(",") if name.strip()] + part_names_list = split_part_names(part_names) response = await sync_op( cls, @@ -868,14 +1572,14 @@ class TripoConversionNode(IO.ComfyNode): force_symmetry=force_symmetry if force_symmetry else None, face_limit=face_limit if face_limit != -1 else None, flatten_bottom=flatten_bottom if flatten_bottom else None, - flatten_bottom_threshold=flatten_bottom_threshold if flatten_bottom_threshold != 0.0 else None, + flatten_bottom_threshold=flatten_bottom_threshold if flatten_bottom else None, texture_size=texture_size if texture_size != 4096 else None, texture_format=texture_format if texture_format != "JPEG" else None, pivot_to_center_bottom=pivot_to_center_bottom if pivot_to_center_bottom else None, scale_factor=scale_factor if scale_factor != 1.0 else None, - with_animation=with_animation if with_animation else None, + with_animation=with_animation, pack_uv=pack_uv if pack_uv else None, - bake=bake if bake else None, + bake=bake, part_names=part_names_list, fbx_preset=fbx_preset if fbx_preset != "blender" else None, export_vertex_colors=export_vertex_colors if export_vertex_colors else None, @@ -883,7 +1587,8 @@ class TripoConversionNode(IO.ComfyNode): animate_in_place=animate_in_place if animate_in_place else None, ), ) - return await poll_until_finished(cls, response, average_duration=30) + _, model = await poll_until_finished(cls, response, average_duration=30) + return IO.NodeOutput(model) class TripoImportModelNode(IO.ComfyNode): @@ -970,12 +1675,13 @@ class TripoImportModelNode(IO.ComfyNode): return IO.NodeOutput(task_id) -def _p1_price_expr(*, geometry_credits: int, textured_credits: int, detailed_credits: int) -> str: +def _p1_price_expr(*, geometry_credits: int, textured_credits: int, detailed_credits: int, extreme_credits: int) -> str: return ( "(" " $mode := widgets.output_mode;" - ' $detailed := $lookup(widgets, "output_mode.texture_quality") = "detailed";' - f' $credits := $mode = "geometry only" ? {geometry_credits} : ($detailed ? {detailed_credits} : {textured_credits});' + ' $tq := $lookup(widgets, "output_mode.texture_quality");' + f' $textured := $tq = "extreme" ? {extreme_credits} : ($tq = "detailed" ? {detailed_credits} : {textured_credits});' + f' $credits := $mode = "geometry only" ? {geometry_credits} : $textured;' ' {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}}' ")" ) @@ -985,7 +1691,12 @@ def _p1_textured_inputs(*, include_image_alignment: bool) -> list: """Inputs shown inside the 'Textured' branch of the P1 output_mode DynamicCombo.""" inputs: list = [ IO.Boolean.Input("pbr", default=True, tooltip="Include PBR maps. When on, base texture is forced on too."), - IO.Combo.Input("texture_quality", options=["standard", "detailed"], default="standard"), + IO.Combo.Input( + "texture_quality", + options=["standard", "detailed", "extreme"], + default="standard", + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", + ), ] if include_image_alignment: inputs.extend( @@ -1004,7 +1715,7 @@ def _p1_textured_inputs(*, include_image_alignment: bool) -> list: ), ] ) - inputs.append(IO.Int.Input("texture_seed", default=42, advanced=True)) + inputs.append(IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, advanced=True)) return inputs @@ -1053,7 +1764,7 @@ def _p1_common_inputs() -> list: advanced=True, tooltip="Target face count, 48-20000. -1 lets Tripo pick adaptively.", ), - IO.Int.Input("model_seed", default=42, optional=True, advanced=True), + IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), IO.Boolean.Input( "auto_size", default=False, @@ -1073,7 +1784,8 @@ def _p1_common_inputs() -> list: default=False, optional=True, advanced=True, - tooltip="Apply geometry-based compression. Decompress before editing.", + tooltip="Apply meshopt geometry compression (EXT_meshopt_compression). Smaller files, " + "but ComfyUI's 3D preview cannot display them; decompress before editing.", ), ] @@ -1112,7 +1824,7 @@ class TripoP1TextToModelNode(IO.ComfyNode): IO.String.Input("prompt", multiline=True, tooltip="Up to 1024 characters."), IO.String.Input("negative_prompt", multiline=True, optional=True, tooltip="Up to 255 characters."), _build_p1_output_mode(include_image_alignment=False), - IO.Int.Input("image_seed", default=42, optional=True, advanced=True), + IO.Int.Input("image_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), *_p1_common_inputs(), ], outputs=[ @@ -1128,7 +1840,7 @@ class TripoP1TextToModelNode(IO.ComfyNode): is_api_node=True, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["output_mode", "output_mode.texture_quality"]), - expr=_p1_price_expr(geometry_credits=30, textured_credits=40, detailed_credits=50), + expr=_p1_price_expr(geometry_credits=30, textured_credits=40, detailed_credits=50, extreme_credits=60), ), ) @@ -1145,7 +1857,7 @@ class TripoP1TextToModelNode(IO.ComfyNode): export_uv: bool = True, compress_geometry: bool = False, ) -> IO.NodeOutput: - if not prompt: + if not prompt.strip(): raise RuntimeError("Prompt is required") common = _build_p1_request_kwargs( output_mode=output_mode, @@ -1167,7 +1879,7 @@ class TripoP1TextToModelNode(IO.ComfyNode): response_model=TripoTaskResponse, data=request, ) - return await poll_until_finished(cls, response, average_duration=60) + return glb_output(*await poll_until_finished(cls, response, average_duration=60)) class TripoP1ImageToModelNode(IO.ComfyNode): @@ -1204,7 +1916,7 @@ class TripoP1ImageToModelNode(IO.ComfyNode): is_api_node=True, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["output_mode", "output_mode.texture_quality"]), - expr=_p1_price_expr(geometry_credits=40, textured_credits=50, detailed_credits=60), + expr=_p1_price_expr(geometry_credits=40, textured_credits=50, detailed_credits=60, extreme_credits=70), ), ) @@ -1247,7 +1959,7 @@ class TripoP1ImageToModelNode(IO.ComfyNode): response_model=TripoTaskResponse, data=request, ) - return await poll_until_finished(cls, response, average_duration=60) + return glb_output(*await poll_until_finished(cls, response, average_duration=60)) class TripoP1MultiviewToModelNode(IO.ComfyNode): @@ -1289,7 +2001,7 @@ class TripoP1MultiviewToModelNode(IO.ComfyNode): is_api_node=True, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["output_mode", "output_mode.texture_quality"]), - expr=_p1_price_expr(geometry_credits=40, textured_credits=50, detailed_credits=60), + expr=_p1_price_expr(geometry_credits=40, textured_credits=50, detailed_credits=60, extreme_credits=70), ), ) @@ -1334,7 +2046,7 @@ class TripoP1MultiviewToModelNode(IO.ComfyNode): response_model=TripoTaskResponse, data=request, ) - return await poll_until_finished(cls, response, average_duration=80) + return glb_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoExtension(ComfyExtension): @@ -1348,9 +2060,15 @@ class TripoExtension(ComfyExtension): TripoP1ImageToModelNode, TripoP1MultiviewToModelNode, TripoImportModelNode, + TripoImageToMultiviewNode, + TripoEditMultiviewNode, TripoTextureNode, + TripoRigCheckNode, TripoRigNode, TripoRetargetNode, + TripoSegmentNode, + TripoMeshCompleteNode, + TripoRetopologyNode, TripoConversionNode, ]