From 0163ccdb0a7f42925c9700989ab9924b9b247535 Mon Sep 17 00:00:00 2001 From: Hunter Senft-Grupp Date: Wed, 5 Aug 2026 06:43:26 +0000 Subject: [PATCH] Add Comfy Cloud 3D nodes Amp-Thread-ID: https://ampcode.com/threads/T-019fd09b-9b3a-70cb-8915-957f1e409a5c Co-authored-by: Amp --- comfy_api_nodes/apis/comfy_cloud.py | 14 ++ comfy_api_nodes/nodes_comfy_cloud.py | 142 ++++++++++++++++++ .../comfy_api_nodes_test/comfy_cloud_test.py | 77 ++++++++++ 3 files changed, 233 insertions(+) diff --git a/comfy_api_nodes/apis/comfy_cloud.py b/comfy_api_nodes/apis/comfy_cloud.py index 5a98f6b32..aedb4112b 100644 --- a/comfy_api_nodes/apis/comfy_cloud.py +++ b/comfy_api_nodes/apis/comfy_cloud.py @@ -26,6 +26,11 @@ ComfyCloudWorkflow = Literal[ "audio.chatterbox-dialogue.v1", "audio.chatterbox-voice-conversion.v1", "audio.melbandroformer-stem-separation.v1", + "3d.triposplat-image-to-gaussian-splat.v1", + "3d.hunyuan3d-2-1-image-to-3d.v1", + "3d.hunyuan3d-multiview-to-3d.v1", + "3d.moge-2-photo-to-textured-mesh.v1", + "3d.moge-2-panorama-to-3d-scene.v1", ] @@ -64,6 +69,15 @@ class ComfyCloudWorkflowInputs(BaseModel): cfg_weight: float | None = Field(None) temperature: float | None = Field(None) script: str | None = Field(None) + remove_background: bool | None = Field(None) + gaussian_count: int | None = Field(None) + fov_degrees: float | None = Field(None) + detail: int | None = Field(None) + mesh_decimation: int | None = Field(None) + gap_threshold: float | None = Field(None) + texture: bool | None = Field(None) + split_resolution: int | None = Field(None) + merge_resolution: int | None = Field(None) class ComfyCloudAssetInput(BaseModel): diff --git a/comfy_api_nodes/nodes_comfy_cloud.py b/comfy_api_nodes/nodes_comfy_cloud.py index 8709be168..37243b339 100644 --- a/comfy_api_nodes/nodes_comfy_cloud.py +++ b/comfy_api_nodes/nodes_comfy_cloud.py @@ -14,6 +14,7 @@ from comfy_api_nodes.apis.comfy_cloud import ( from comfy_api_nodes.util import ( ApiEndpoint, download_url_to_audio_input, + download_url_to_file_3d, download_url_to_image_tensor, download_url_to_video_output, get_number_of_images, @@ -755,6 +756,142 @@ class ComfyCloudMelBandRoFormerStemSeparationNode(IO.ComfyNode): return await _run_audio_workflow(cls, "audio.melbandroformer-stem-separation.v1", ComfyCloudWorkflowInputs(assets=await _audio_asset(cls, "audio", audio)), ("vocals", "instruments")) +async def _run_3d_workflow(cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs, file_format: str) -> IO.NodeOutput: + task = await sync_op(cls, _GENERATE_ENDPOINT, response_model=ComfyCloudGenerateResponse, data=ComfyCloudGenerateRequest(workflow=workflow, inputs=inputs)) + result = await poll_op( + cls, + ApiEndpoint(path=task.polling_url), + response_model=ComfyCloudStatusResponse, + status_extractor=lambda response: response.status, + progress_extractor=lambda response: response.progress, + cancel_endpoint=ApiEndpoint(path=task.cancel_url, method="POST"), + ) + if not result.output_url: + detail = f": {result.error}" if result.error else "" + raise RuntimeError(f"Comfy Cloud task {result.task_id} completed without an output URL{detail}") + return IO.NodeOutput(await download_url_to_file_3d(result.output_url, file_format, cls=cls)) + + +def _3d_schema(node_id: str, display_name: str, inputs: list[IO.Input], output: IO.Output) -> IO.Schema: + return IO.Schema( + node_id=node_id, + display_name=display_name, + category="partner/3d/Comfy Cloud", + inputs=inputs, + outputs=[output], + hidden=[IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id], + is_api_node=True, + ) + + +async def _image_asset(cls: type[IO.ComfyNode], name: str, image: Input.Image, wait_label: str | None = None) -> dict[str, ComfyCloudAssetInput]: + if get_number_of_images(image) != 1: + raise ValueError(f"Exactly one {name.replace('_', ' ')} is required.") + kwargs = {"total_pixels": None} + if wait_label is not None: + kwargs["wait_label"] = wait_label + return {name: ComfyCloudAssetInput(type="IMAGE", url=await upload_image_to_comfyapi(cls, image, **kwargs))} + + +class ComfyCloudTripoSplatImageToGaussianSplatNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return _3d_schema( + "ComfyCloudTripoSplatImageToGaussianSplatNode", + "TripoSplat Image to Gaussian Splat", + [ + IO.Image.Input("image"), + IO.Boolean.Input("remove_background", default=True), + IO.Int.Input("seed", default=46, min=0, max=_UINT64_MAX, control_after_generate=True), + IO.Int.Input("gaussian_count", default=262144, min=32768, max=262144), + ], + IO.File3DSPZ.Output(tooltip="SPZ Gaussian splat (.spz; POC MIME application/octet-stream)."), + ) + + @classmethod + async def execute(cls, image: Input.Image, remove_background: bool, seed: int, gaussian_count: int) -> IO.NodeOutput: + return await _run_3d_workflow(cls, "3d.triposplat-image-to-gaussian-splat.v1", ComfyCloudWorkflowInputs(assets=await _image_asset(cls, "image", image), remove_background=remove_background, seed=seed, gaussian_count=gaussian_count), "spz") + + +class ComfyCloudHunyuan3D21ImageTo3DNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return _3d_schema( + "ComfyCloudHunyuan3D21ImageTo3DNode", + "Hunyuan3D 2.1 Image to 3D", + [IO.Image.Input("image"), IO.Int.Input("seed", default=952805179515179, min=0, max=_UINT64_MAX, control_after_generate=True)], + IO.File3DGLB.Output(), + ) + + @classmethod + async def execute(cls, image: Input.Image, seed: int) -> IO.NodeOutput: + return await _run_3d_workflow(cls, "3d.hunyuan3d-2-1-image-to-3d.v1", ComfyCloudWorkflowInputs(assets=await _image_asset(cls, "image", image), seed=seed), "glb") + + +class ComfyCloudHunyuan3DMultiViewTo3DNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return _3d_schema( + "ComfyCloudHunyuan3DMultiViewTo3DNode", + "Hunyuan3D Multi-View to 3D", + [IO.Image.Input("front_image"), IO.Image.Input("back_image"), IO.Int.Input("seed", default=502126049100058, min=0, max=_UINT64_MAX, control_after_generate=True)], + IO.File3DGLB.Output(), + ) + + @classmethod + async def execute(cls, front_image: Input.Image, back_image: Input.Image, seed: int) -> IO.NodeOutput: + assets = await _image_asset(cls, "front_image", front_image, "Uploading front image") + assets.update(await _image_asset(cls, "back_image", back_image, "Uploading back image")) + return await _run_3d_workflow(cls, "3d.hunyuan3d-multiview-to-3d.v1", ComfyCloudWorkflowInputs(assets=assets, seed=seed), "glb") + + +class ComfyCloudMoGe2PhotoToTexturedMeshNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return _3d_schema( + "ComfyCloudMoGe2PhotoToTexturedMeshNode", + "MoGe 2 Photo to Textured Mesh", + [ + IO.Image.Input("image"), + IO.Float.Input("fov_degrees", default=0, min=0, max=170, step=0.1, tooltip="0 selects automatic field-of-view estimation."), + IO.Int.Input("detail", default=9, min=0, max=9), + IO.Int.Input("mesh_decimation", default=1, min=1, max=8), + IO.Float.Input("gap_threshold", default=0.04, min=0, max=1, step=0.01), + IO.Boolean.Input("texture", default=True), + ], + IO.File3DGLB.Output(), + ) + + @classmethod + async def execute(cls, image: Input.Image, fov_degrees: float, detail: int, mesh_decimation: int, gap_threshold: float, texture: bool) -> IO.NodeOutput: + inputs = ComfyCloudWorkflowInputs(assets=await _image_asset(cls, "image", image), fov_degrees=fov_degrees, detail=detail, mesh_decimation=mesh_decimation, gap_threshold=gap_threshold, texture=texture) + return await _run_3d_workflow(cls, "3d.moge-2-photo-to-textured-mesh.v1", inputs, "glb") + + +class ComfyCloudMoGe2PanoramaTo3DSceneNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return _3d_schema( + "ComfyCloudMoGe2PanoramaTo3DSceneNode", + "MoGe 2 Panorama to 3D Scene", + [ + IO.Image.Input("panorama", tooltip="Equirectangular panorama."), + IO.Int.Input("detail", default=5, min=0, max=9), + IO.Int.Input("split_resolution", default=512, min=256, max=1024), + IO.Int.Input("merge_resolution", default=1024, min=256, max=8192), + IO.Int.Input("mesh_decimation", default=1, min=1, max=8), + IO.Float.Input("gap_threshold", default=0.04, min=0, max=1, step=0.01), + IO.Boolean.Input("texture", default=True), + ], + IO.File3DGLB.Output(), + ) + + @classmethod + async def execute(cls, panorama: Input.Image, detail: int, split_resolution: int, merge_resolution: int, mesh_decimation: int, gap_threshold: float, texture: bool) -> IO.NodeOutput: + inputs = ComfyCloudWorkflowInputs(assets=await _image_asset(cls, "panorama", panorama), detail=detail, split_resolution=split_resolution, merge_resolution=merge_resolution, mesh_decimation=mesh_decimation, gap_threshold=gap_threshold, texture=texture) + return await _run_3d_workflow(cls, "3d.moge-2-panorama-to-3d-scene.v1", inputs, "glb") + + class ComfyCloudExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -781,6 +918,11 @@ class ComfyCloudExtension(ComfyExtension): ComfyCloudChatterboxDialogueNode, ComfyCloudChatterboxVoiceConversionNode, ComfyCloudMelBandRoFormerStemSeparationNode, + ComfyCloudTripoSplatImageToGaussianSplatNode, + ComfyCloudHunyuan3D21ImageTo3DNode, + ComfyCloudHunyuan3DMultiViewTo3DNode, + ComfyCloudMoGe2PhotoToTexturedMeshNode, + ComfyCloudMoGe2PanoramaTo3DSceneNode, ] diff --git a/tests-unit/comfy_api_nodes_test/comfy_cloud_test.py b/tests-unit/comfy_api_nodes_test/comfy_cloud_test.py index e7678a7f7..e052075b8 100644 --- a/tests-unit/comfy_api_nodes_test/comfy_cloud_test.py +++ b/tests-unit/comfy_api_nodes_test/comfy_cloud_test.py @@ -462,3 +462,80 @@ def test_download_cloud_3d_url_to_file_3d(monkeypatch, file_format, expected_for assert calls[0][0] == f"/proxy/comfy-cloud/results/task-1/model.{expected_format}" assert isinstance(calls[0][1], BytesIO) assert calls[0][2] == {"timeout": 45, "max_retries": 3, "cls": node} + + +THREE_D_POC_NODES = [ + (nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode, "3d.triposplat-image-to-gaussian-splat.v1", ["image", "remove_background", "seed", "gaussian_count"], {"image": object(), "remove_background": False, "seed": 7, "gaussian_count": 32768}, "FILE_3D_SPZ", "spz"), + (nodes_comfy_cloud.ComfyCloudHunyuan3D21ImageTo3DNode, "3d.hunyuan3d-2-1-image-to-3d.v1", ["image", "seed"], {"image": object(), "seed": 8}, "FILE_3D_GLB", "glb"), + (nodes_comfy_cloud.ComfyCloudHunyuan3DMultiViewTo3DNode, "3d.hunyuan3d-multiview-to-3d.v1", ["front_image", "back_image", "seed"], {"front_image": object(), "back_image": object(), "seed": 9}, "FILE_3D_GLB", "glb"), + (nodes_comfy_cloud.ComfyCloudMoGe2PhotoToTexturedMeshNode, "3d.moge-2-photo-to-textured-mesh.v1", ["image", "fov_degrees", "detail", "mesh_decimation", "gap_threshold", "texture"], {"image": object(), "fov_degrees": 45.5, "detail": 8, "mesh_decimation": 2, "gap_threshold": 0.05, "texture": False}, "FILE_3D_GLB", "glb"), + (nodes_comfy_cloud.ComfyCloudMoGe2PanoramaTo3DSceneNode, "3d.moge-2-panorama-to-3d-scene.v1", ["panorama", "detail", "split_resolution", "merge_resolution", "mesh_decimation", "gap_threshold", "texture"], {"panorama": object(), "detail": 6, "split_resolution": 768, "merge_resolution": 2048, "mesh_decimation": 3, "gap_threshold": 0.06, "texture": False}, "FILE_3D_GLB", "glb"), +] + + +@pytest.mark.parametrize(("node", "workflow", "input_names", "arguments", "output_type", "file_format"), THREE_D_POC_NODES) +def test_3d_poc_node_schema_request_mapping_and_registration(monkeypatch, node, workflow, input_names, arguments, output_type, file_format): + run = AsyncMock(return_value=("3d-output",)) + upload = AsyncMock(side_effect=["/uploads/front.png", "/uploads/back.png"]) + monkeypatch.setattr(nodes_comfy_cloud, "_run_3d_workflow", run) + monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload) + monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1) + + schema = node.define_schema() + assert schema.is_api_node + assert schema.category == "partner/3d/Comfy Cloud" + assert [input.id for input in schema.inputs] == input_names + assert schema.outputs[0].get_io_type() == output_type + assert workflow in get_args(ComfyCloudWorkflow) + assert node in asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list()) + + output = asyncio.run(node.execute(**arguments)) + assert output[0] == "3d-output" + assert run.call_args.args[1] == workflow + assert run.call_args.args[3] == file_format + request_inputs = run.call_args.args[2].model_dump(exclude_none=True) + image_names = [name for name in ("image", "front_image", "back_image", "panorama") if name in arguments] + expected_inputs = {name: value for name, value in arguments.items() if name not in image_names} + expected_inputs["assets"] = { + name: {"type": "IMAGE", "url": f"/uploads/{'front' if index == 0 else 'back'}.png"} + for index, name in enumerate(image_names) + } + assert request_inputs == expected_inputs + assert '"id"' not in run.call_args.args[2].model_dump_json() + + +def test_3d_poc_schema_defaults_and_ranges(): + schemas = {workflow: {input.id: input for input in node.define_schema().inputs} for node, workflow, _, _, _, _ in THREE_D_POC_NODES} + tripo = schemas["3d.triposplat-image-to-gaussian-splat.v1"] + assert tripo["remove_background"].default is True + assert (tripo["seed"].default, tripo["seed"].min, tripo["seed"].max) == (46, 0, 0xFFFFFFFFFFFFFFFF) + assert (tripo["gaussian_count"].default, tripo["gaussian_count"].min, tripo["gaussian_count"].max) == (262144, 32768, 262144) + assert "application/octet-stream" in nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode.define_schema().outputs[0].tooltip + assert schemas["3d.hunyuan3d-2-1-image-to-3d.v1"]["seed"].default == 952805179515179 + assert schemas["3d.hunyuan3d-multiview-to-3d.v1"]["seed"].default == 502126049100058 + photo = schemas["3d.moge-2-photo-to-textured-mesh.v1"] + assert (photo["fov_degrees"].default, photo["fov_degrees"].min, photo["fov_degrees"].max, photo["fov_degrees"].step) == (0, 0, 170, 0.1) + assert (photo["detail"].default, photo["detail"].min, photo["detail"].max) == (9, 0, 9) + panorama = schemas["3d.moge-2-panorama-to-3d-scene.v1"] + assert (panorama["split_resolution"].default, panorama["split_resolution"].min, panorama["split_resolution"].max) == (512, 256, 1024) + assert (panorama["merge_resolution"].default, panorama["merge_resolution"].min, panorama["merge_resolution"].max) == (1024, 256, 8192) + + +def test_3d_workflow_submission_polling_cancel_and_download(monkeypatch): + sync = AsyncMock(return_value=ComfyCloudGenerateResponse(task_id="task-3d", status="queued", polling_url="/tasks/task-3d", cancel_url="/tasks/task-3d/cancel")) + poll = AsyncMock(return_value=ComfyCloudStatusResponse(task_id="task-3d", status="completed", output_url="/results/model.spz")) + download = AsyncMock(return_value="spz-output") + monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync) + monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll) + monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_file_3d", download) + + output = asyncio.run(nodes_comfy_cloud._run_3d_workflow(nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode, "3d.triposplat-image-to-gaussian-splat.v1", ComfyCloudWorkflowInputs(seed=46), "spz")) + + request = sync.call_args.kwargs["data"] + assert request.workflow == "3d.triposplat-image-to-gaussian-splat.v1" + assert request.inputs.model_dump(exclude_none=True) == {"seed": 46} + assert poll.call_args.args[1].path == "/tasks/task-3d" + assert poll.call_args.kwargs["cancel_endpoint"].path == "/tasks/task-3d/cancel" + assert poll.call_args.kwargs["cancel_endpoint"].method == "POST" + download.assert_awaited_once_with("/results/model.spz", "spz", cls=nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode) + assert output[0] == "spz-output"