Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(comfy_api): add basic 3D Model file types (Comfy-Org#12129)
* feat(comfy_api): add basic 3D Model file types

* update Tripo nodes to use File3DGLB

* update Rodin3D nodes to use File3DGLB

* address PR review feedback:

- Rename File3D parameter 'path' to 'source'
- Convert File3D.data property to get_data()
- Make .glb extension check case-insensitive in nodes_rodin.py
- Restrict SaveGLB node to only accept File3DGLB

* Fixed a bug in the Meshy Rig and Animation nodes

* Fix backward compatability
  • Loading branch information
bigcat88 authored Feb 3, 2026
commit fb23935c1139875d47e7b6e239f657bae73bda5b
3 changes: 2 additions & 1 deletion comfy_api/latest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from comfy_api.internal.async_to_sync import create_sync_class
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
from ._input_impl import VideoFromFile, VideoFromComponents
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL, File3D
from . import _io_public as io
from . import _ui_public as ui
from comfy_execution.utils import get_executing_context
Expand Down Expand Up @@ -105,6 +105,7 @@ class Types:
VideoComponents = VideoComponents
MESH = MESH
VOXEL = VOXEL
File3D = File3D

ComfyAPI = ComfyAPI_latest

Expand Down
52 changes: 51 additions & 1 deletion comfy_api/latest/_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from comfy_api.internal import (_ComfyNodeInternal, _NodeOutputInternal, classproperty, copy_class, first_real_override, is_class,
prune_dict, shallow_clone_class)
from comfy_execution.graph_utils import ExecutionBlocker
from ._util import MESH, VOXEL, SVG as _SVG
from ._util import MESH, VOXEL, SVG as _SVG, File3D


class FolderType(str, Enum):
Expand Down Expand Up @@ -667,6 +667,49 @@ class Voxel(ComfyTypeIO):
class Mesh(ComfyTypeIO):
Type = MESH


@comfytype(io_type="FILE_3D")
class File3DAny(ComfyTypeIO):
"""General 3D file type - accepts any supported 3D format."""
Type = File3D


@comfytype(io_type="FILE_3D_GLB")
class File3DGLB(ComfyTypeIO):
"""GLB format 3D file - binary glTF, best for web and cross-platform."""
Type = File3D


@comfytype(io_type="FILE_3D_GLTF")
class File3DGLTF(ComfyTypeIO):
"""GLTF format 3D file - JSON-based glTF with external resources."""
Type = File3D


@comfytype(io_type="FILE_3D_FBX")
class File3DFBX(ComfyTypeIO):
"""FBX format 3D file - best for game engines and animation."""
Type = File3D


@comfytype(io_type="FILE_3D_OBJ")
class File3DOBJ(ComfyTypeIO):
"""OBJ format 3D file - simple geometry format."""
Type = File3D


@comfytype(io_type="FILE_3D_STL")
class File3DSTL(ComfyTypeIO):
"""STL format 3D file - best for 3D printing."""
Type = File3D


@comfytype(io_type="FILE_3D_USDZ")
class File3DUSDZ(ComfyTypeIO):
"""USDZ format 3D file - Apple AR format."""
Type = File3D


@comfytype(io_type="HOOKS")
class Hooks(ComfyTypeIO):
if TYPE_CHECKING:
Expand Down Expand Up @@ -2037,6 +2080,13 @@ def as_dict(self) -> dict:
"LossMap",
"Voxel",
"Mesh",
"File3DAny",
"File3DGLB",
"File3DGLTF",
"File3DFBX",
"File3DOBJ",
"File3DSTL",
"File3DUSDZ",
"Hooks",
"HookKeyframes",
"TimestepsRange",
Expand Down
3 changes: 2 additions & 1 deletion comfy_api/latest/_util/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .video_types import VideoContainer, VideoCodec, VideoComponents
from .geometry_types import VOXEL, MESH
from .geometry_types import VOXEL, MESH, File3D
from .image_types import SVG

__all__ = [
Expand All @@ -9,5 +9,6 @@
"VideoComponents",
"VOXEL",
"MESH",
"File3D",
"SVG",
]
77 changes: 77 additions & 0 deletions comfy_api/latest/_util/geometry_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import shutil
from io import BytesIO
from pathlib import Path
from typing import IO

import torch


Expand All @@ -10,3 +15,75 @@ class MESH:
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor):
self.vertices = vertices
self.faces = faces


class File3D:
"""Class representing a 3D file from a file path or binary stream.

Supports both disk-backed (file path) and memory-backed (BytesIO) storage.
"""

def __init__(self, source: str | IO[bytes], file_format: str = ""):
self._source = source
self._format = file_format or self._infer_format()

def _infer_format(self) -> str:
if isinstance(self._source, str):
return Path(self._source).suffix.lstrip(".").lower()
return ""

@property
def format(self) -> str:
return self._format

@format.setter
def format(self, value: str) -> None:
self._format = value.lstrip(".").lower() if value else ""

@property
def is_disk_backed(self) -> bool:
return isinstance(self._source, str)

def get_source(self) -> str | IO[bytes]:
if isinstance(self._source, str):
return self._source
if hasattr(self._source, "seek"):
self._source.seek(0)
return self._source

def get_data(self) -> BytesIO:
if isinstance(self._source, str):
with open(self._source, "rb") as f:
result = BytesIO(f.read())
return result
if hasattr(self._source, "seek"):
self._source.seek(0)
if isinstance(self._source, BytesIO):
return self._source
return BytesIO(self._source.read())

def save_to(self, path: str) -> str:
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)

if isinstance(self._source, str):
if Path(self._source).resolve() != dest.resolve():
shutil.copy2(self._source, dest)
else:
if hasattr(self._source, "seek"):
self._source.seek(0)
with open(dest, "wb") as f:
f.write(self._source.read())
return str(dest)

def get_bytes(self) -> bytes:
if isinstance(self._source, str):
return Path(self._source).read_bytes()
if hasattr(self._source, "seek"):
self._source.seek(0)
return self._source.read()

def __repr__(self) -> str:
if isinstance(self._source, str):
return f"File3D(source={self._source!r}, format={self._format!r})"
return f"File3D(<stream>, format={self._format!r})"
5 changes: 5 additions & 0 deletions comfy_api_nodes/apis/meshy.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,19 @@ class MeshyTextureRequest(BaseModel):

class MeshyModelsUrls(BaseModel):
glb: str = Field("")
fbx: str = Field("")
usdz: str = Field("")
obj: str = Field("")


class MeshyRiggedModelsUrls(BaseModel):
rigged_character_glb_url: str = Field("")
rigged_character_fbx_url: str = Field("")


class MeshyAnimatedModelsUrls(BaseModel):
animation_glb_url: str = Field("")
animation_fbx_url: str = Field("")


class MeshyResultTextureUrls(BaseModel):
Expand Down
45 changes: 24 additions & 21 deletions comfy_api_nodes/nodes_hunyuan3d.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import os

from typing_extensions import override

from comfy_api.latest import IO, ComfyExtension, Input
Expand All @@ -14,22 +12,21 @@
)
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_bytesio,
download_url_to_file_3d,
downscale_image_tensor_by_max_side,
poll_op,
sync_op,
upload_image_to_comfyapi,
validate_image_dimensions,
validate_string,
)
from folder_paths import get_output_directory


def get_glb_obj_from_response(response_objs: list[ResultFile3D]) -> ResultFile3D:
def get_file_from_response(response_objs: list[ResultFile3D], file_type: str) -> ResultFile3D | None:
for i in response_objs:
if i.Type.lower() == "glb":
if i.Type.lower() == file_type.lower():
return i
raise ValueError("No GLB file found in response. Please report this to the developers.")
return None


class TencentTextToModelNode(IO.ComfyNode):
Expand Down Expand Up @@ -74,7 +71,9 @@ def define_schema(cls):
),
],
outputs=[
IO.String.Output(display_name="model_file"),
IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DOBJ.Output(display_name="OBJ"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
Expand Down Expand Up @@ -124,19 +123,20 @@ async def execute(
)
if response.Error:
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
task_id = response.JobId
result = await poll_op(
cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
data=To3DProTaskQueryRequest(JobId=response.JobId),
data=To3DProTaskQueryRequest(JobId=task_id),
response_model=To3DProTaskResultResponse,
status_extractor=lambda r: r.Status,
)
model_file = f"hunyuan_model_{response.JobId}.glb"
await download_url_to_bytesio(
get_glb_obj_from_response(result.ResultFile3Ds).Url,
os.path.join(get_output_directory(), model_file),
glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
return IO.NodeOutput(
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
)
return IO.NodeOutput(model_file)


class TencentImageToModelNode(IO.ComfyNode):
Expand Down Expand Up @@ -184,7 +184,9 @@ def define_schema(cls):
),
],
outputs=[
IO.String.Output(display_name="model_file"),
IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DOBJ.Output(display_name="OBJ"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
Expand Down Expand Up @@ -269,19 +271,20 @@ async def execute(
)
if response.Error:
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
task_id = response.JobId
result = await poll_op(
cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
data=To3DProTaskQueryRequest(JobId=response.JobId),
data=To3DProTaskQueryRequest(JobId=task_id),
response_model=To3DProTaskResultResponse,
status_extractor=lambda r: r.Status,
)
model_file = f"hunyuan_model_{response.JobId}.glb"
await download_url_to_bytesio(
get_glb_obj_from_response(result.ResultFile3Ds).Url,
os.path.join(get_output_directory(), model_file),
glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
return IO.NodeOutput(
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
)
return IO.NodeOutput(model_file)


class TencentHunyuan3DExtension(ComfyExtension):
Expand Down
Loading