Skip to content

Commit fb23935

Browse files
authored
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
1 parent 85fc35e commit fb23935

File tree

13 files changed

+427
-160
lines changed

13 files changed

+427
-160
lines changed

comfy_api/latest/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from comfy_api.internal.async_to_sync import create_sync_class
88
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
99
from ._input_impl import VideoFromFile, VideoFromComponents
10-
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL
10+
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL, File3D
1111
from . import _io_public as io
1212
from . import _ui_public as ui
1313
from comfy_execution.utils import get_executing_context
@@ -105,6 +105,7 @@ class Types:
105105
VideoComponents = VideoComponents
106106
MESH = MESH
107107
VOXEL = VOXEL
108+
File3D = File3D
108109

109110
ComfyAPI = ComfyAPI_latest
110111

comfy_api/latest/_io.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from comfy_api.internal import (_ComfyNodeInternal, _NodeOutputInternal, classproperty, copy_class, first_real_override, is_class,
2828
prune_dict, shallow_clone_class)
2929
from comfy_execution.graph_utils import ExecutionBlocker
30-
from ._util import MESH, VOXEL, SVG as _SVG
30+
from ._util import MESH, VOXEL, SVG as _SVG, File3D
3131

3232

3333
class FolderType(str, Enum):
@@ -667,6 +667,49 @@ class Voxel(ComfyTypeIO):
667667
class Mesh(ComfyTypeIO):
668668
Type = MESH
669669

670+
671+
@comfytype(io_type="FILE_3D")
672+
class File3DAny(ComfyTypeIO):
673+
"""General 3D file type - accepts any supported 3D format."""
674+
Type = File3D
675+
676+
677+
@comfytype(io_type="FILE_3D_GLB")
678+
class File3DGLB(ComfyTypeIO):
679+
"""GLB format 3D file - binary glTF, best for web and cross-platform."""
680+
Type = File3D
681+
682+
683+
@comfytype(io_type="FILE_3D_GLTF")
684+
class File3DGLTF(ComfyTypeIO):
685+
"""GLTF format 3D file - JSON-based glTF with external resources."""
686+
Type = File3D
687+
688+
689+
@comfytype(io_type="FILE_3D_FBX")
690+
class File3DFBX(ComfyTypeIO):
691+
"""FBX format 3D file - best for game engines and animation."""
692+
Type = File3D
693+
694+
695+
@comfytype(io_type="FILE_3D_OBJ")
696+
class File3DOBJ(ComfyTypeIO):
697+
"""OBJ format 3D file - simple geometry format."""
698+
Type = File3D
699+
700+
701+
@comfytype(io_type="FILE_3D_STL")
702+
class File3DSTL(ComfyTypeIO):
703+
"""STL format 3D file - best for 3D printing."""
704+
Type = File3D
705+
706+
707+
@comfytype(io_type="FILE_3D_USDZ")
708+
class File3DUSDZ(ComfyTypeIO):
709+
"""USDZ format 3D file - Apple AR format."""
710+
Type = File3D
711+
712+
670713
@comfytype(io_type="HOOKS")
671714
class Hooks(ComfyTypeIO):
672715
if TYPE_CHECKING:
@@ -2037,6 +2080,13 @@ def as_dict(self) -> dict:
20372080
"LossMap",
20382081
"Voxel",
20392082
"Mesh",
2083+
"File3DAny",
2084+
"File3DGLB",
2085+
"File3DGLTF",
2086+
"File3DFBX",
2087+
"File3DOBJ",
2088+
"File3DSTL",
2089+
"File3DUSDZ",
20402090
"Hooks",
20412091
"HookKeyframes",
20422092
"TimestepsRange",

comfy_api/latest/_util/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .video_types import VideoContainer, VideoCodec, VideoComponents
2-
from .geometry_types import VOXEL, MESH
2+
from .geometry_types import VOXEL, MESH, File3D
33
from .image_types import SVG
44

55
__all__ = [
@@ -9,5 +9,6 @@
99
"VideoComponents",
1010
"VOXEL",
1111
"MESH",
12+
"File3D",
1213
"SVG",
1314
]

comfy_api/latest/_util/geometry_types.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import shutil
2+
from io import BytesIO
3+
from pathlib import Path
4+
from typing import IO
5+
16
import torch
27

38

@@ -10,3 +15,75 @@ class MESH:
1015
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor):
1116
self.vertices = vertices
1217
self.faces = faces
18+
19+
20+
class File3D:
21+
"""Class representing a 3D file from a file path or binary stream.
22+
23+
Supports both disk-backed (file path) and memory-backed (BytesIO) storage.
24+
"""
25+
26+
def __init__(self, source: str | IO[bytes], file_format: str = ""):
27+
self._source = source
28+
self._format = file_format or self._infer_format()
29+
30+
def _infer_format(self) -> str:
31+
if isinstance(self._source, str):
32+
return Path(self._source).suffix.lstrip(".").lower()
33+
return ""
34+
35+
@property
36+
def format(self) -> str:
37+
return self._format
38+
39+
@format.setter
40+
def format(self, value: str) -> None:
41+
self._format = value.lstrip(".").lower() if value else ""
42+
43+
@property
44+
def is_disk_backed(self) -> bool:
45+
return isinstance(self._source, str)
46+
47+
def get_source(self) -> str | IO[bytes]:
48+
if isinstance(self._source, str):
49+
return self._source
50+
if hasattr(self._source, "seek"):
51+
self._source.seek(0)
52+
return self._source
53+
54+
def get_data(self) -> BytesIO:
55+
if isinstance(self._source, str):
56+
with open(self._source, "rb") as f:
57+
result = BytesIO(f.read())
58+
return result
59+
if hasattr(self._source, "seek"):
60+
self._source.seek(0)
61+
if isinstance(self._source, BytesIO):
62+
return self._source
63+
return BytesIO(self._source.read())
64+
65+
def save_to(self, path: str) -> str:
66+
dest = Path(path)
67+
dest.parent.mkdir(parents=True, exist_ok=True)
68+
69+
if isinstance(self._source, str):
70+
if Path(self._source).resolve() != dest.resolve():
71+
shutil.copy2(self._source, dest)
72+
else:
73+
if hasattr(self._source, "seek"):
74+
self._source.seek(0)
75+
with open(dest, "wb") as f:
76+
f.write(self._source.read())
77+
return str(dest)
78+
79+
def get_bytes(self) -> bytes:
80+
if isinstance(self._source, str):
81+
return Path(self._source).read_bytes()
82+
if hasattr(self._source, "seek"):
83+
self._source.seek(0)
84+
return self._source.read()
85+
86+
def __repr__(self) -> str:
87+
if isinstance(self._source, str):
88+
return f"File3D(source={self._source!r}, format={self._format!r})"
89+
return f"File3D(<stream>, format={self._format!r})"

comfy_api_nodes/apis/meshy.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,19 @@ class MeshyTextureRequest(BaseModel):
109109

110110
class MeshyModelsUrls(BaseModel):
111111
glb: str = Field("")
112+
fbx: str = Field("")
113+
usdz: str = Field("")
114+
obj: str = Field("")
112115

113116

114117
class MeshyRiggedModelsUrls(BaseModel):
115118
rigged_character_glb_url: str = Field("")
119+
rigged_character_fbx_url: str = Field("")
116120

117121

118122
class MeshyAnimatedModelsUrls(BaseModel):
119123
animation_glb_url: str = Field("")
124+
animation_fbx_url: str = Field("")
120125

121126

122127
class MeshyResultTextureUrls(BaseModel):

comfy_api_nodes/nodes_hunyuan3d.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import os
2-
31
from typing_extensions import override
42

53
from comfy_api.latest import IO, ComfyExtension, Input
@@ -14,22 +12,21 @@
1412
)
1513
from comfy_api_nodes.util import (
1614
ApiEndpoint,
17-
download_url_to_bytesio,
15+
download_url_to_file_3d,
1816
downscale_image_tensor_by_max_side,
1917
poll_op,
2018
sync_op,
2119
upload_image_to_comfyapi,
2220
validate_image_dimensions,
2321
validate_string,
2422
)
25-
from folder_paths import get_output_directory
2623

2724

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

3431

3532
class TencentTextToModelNode(IO.ComfyNode):
@@ -74,7 +71,9 @@ def define_schema(cls):
7471
),
7572
],
7673
outputs=[
77-
IO.String.Output(display_name="model_file"),
74+
IO.String.Output(display_name="model_file"), # for backward compatibility only
75+
IO.File3DGLB.Output(display_name="GLB"),
76+
IO.File3DOBJ.Output(display_name="OBJ"),
7877
],
7978
hidden=[
8079
IO.Hidden.auth_token_comfy_org,
@@ -124,19 +123,20 @@ async def execute(
124123
)
125124
if response.Error:
126125
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
126+
task_id = response.JobId
127127
result = await poll_op(
128128
cls,
129129
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
130-
data=To3DProTaskQueryRequest(JobId=response.JobId),
130+
data=To3DProTaskQueryRequest(JobId=task_id),
131131
response_model=To3DProTaskResultResponse,
132132
status_extractor=lambda r: r.Status,
133133
)
134-
model_file = f"hunyuan_model_{response.JobId}.glb"
135-
await download_url_to_bytesio(
136-
get_glb_obj_from_response(result.ResultFile3Ds).Url,
137-
os.path.join(get_output_directory(), model_file),
134+
glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
135+
obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
136+
file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
137+
return IO.NodeOutput(
138+
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
138139
)
139-
return IO.NodeOutput(model_file)
140140

141141

142142
class TencentImageToModelNode(IO.ComfyNode):
@@ -184,7 +184,9 @@ def define_schema(cls):
184184
),
185185
],
186186
outputs=[
187-
IO.String.Output(display_name="model_file"),
187+
IO.String.Output(display_name="model_file"), # for backward compatibility only
188+
IO.File3DGLB.Output(display_name="GLB"),
189+
IO.File3DOBJ.Output(display_name="OBJ"),
188190
],
189191
hidden=[
190192
IO.Hidden.auth_token_comfy_org,
@@ -269,19 +271,20 @@ async def execute(
269271
)
270272
if response.Error:
271273
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
274+
task_id = response.JobId
272275
result = await poll_op(
273276
cls,
274277
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
275-
data=To3DProTaskQueryRequest(JobId=response.JobId),
278+
data=To3DProTaskQueryRequest(JobId=task_id),
276279
response_model=To3DProTaskResultResponse,
277280
status_extractor=lambda r: r.Status,
278281
)
279-
model_file = f"hunyuan_model_{response.JobId}.glb"
280-
await download_url_to_bytesio(
281-
get_glb_obj_from_response(result.ResultFile3Ds).Url,
282-
os.path.join(get_output_directory(), model_file),
282+
glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
283+
obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
284+
file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
285+
return IO.NodeOutput(
286+
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
283287
)
284-
return IO.NodeOutput(model_file)
285288

286289

287290
class TencentHunyuan3DExtension(ComfyExtension):

0 commit comments

Comments
 (0)