Skip to content

Commit f9ceed9

Browse files
authored
fix(api-nodes): Tencent TextToModel and ImageToModel nodes (Comfy-Org#12680)
* fix(api-nodes): added "texture_image" output to TencentTextToModel and TencentImageToModel nodes. Fixed `OBJ` output when it is zipped * support additional solid texture outputs * fixed and enabled Tencent3DTextureEdit node
1 parent 4a8cf35 commit f9ceed9

File tree

1 file changed

+88
-9
lines changed

1 file changed

+88
-9
lines changed

comfy_api_nodes/nodes_hunyuan3d.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import zipfile
2+
from io import BytesIO
3+
4+
import torch
15
from typing_extensions import override
26

37
from comfy_api.latest import IO, ComfyExtension, Input, Types
@@ -17,7 +21,10 @@
1721
)
1822
from comfy_api_nodes.util import (
1923
ApiEndpoint,
24+
bytesio_to_image_tensor,
25+
download_url_to_bytesio,
2026
download_url_to_file_3d,
27+
download_url_to_image_tensor,
2128
downscale_image_tensor_by_max_side,
2229
poll_op,
2330
sync_op,
@@ -36,6 +43,68 @@ def _is_tencent_rate_limited(status: int, body: object) -> bool:
3643
)
3744

3845

46+
class ObjZipResult:
47+
__slots__ = ("obj", "texture", "metallic", "normal", "roughness")
48+
49+
def __init__(
50+
self,
51+
obj: Types.File3D,
52+
texture: Input.Image | None = None,
53+
metallic: Input.Image | None = None,
54+
normal: Input.Image | None = None,
55+
roughness: Input.Image | None = None,
56+
):
57+
self.obj = obj
58+
self.texture = texture
59+
self.metallic = metallic
60+
self.normal = normal
61+
self.roughness = roughness
62+
63+
64+
async def download_and_extract_obj_zip(url: str) -> ObjZipResult:
65+
"""The Tencent API returns OBJ results as ZIP archives containing the .obj mesh, and texture images.
66+
67+
When PBR is enabled, the ZIP may contain additional metallic, normal, and roughness maps
68+
identified by their filename suffixes.
69+
"""
70+
data = BytesIO()
71+
await download_url_to_bytesio(url, data)
72+
data.seek(0)
73+
if not zipfile.is_zipfile(data):
74+
data.seek(0)
75+
return ObjZipResult(obj=Types.File3D(source=data, file_format="obj"))
76+
data.seek(0)
77+
obj_bytes = None
78+
textures: dict[str, Input.Image] = {}
79+
with zipfile.ZipFile(data) as zf:
80+
for name in zf.namelist():
81+
lower = name.lower()
82+
if lower.endswith(".obj"):
83+
obj_bytes = zf.read(name)
84+
elif any(lower.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp")):
85+
stem = lower.rsplit(".", 1)[0]
86+
tensor = bytesio_to_image_tensor(BytesIO(zf.read(name)), mode="RGB")
87+
matched_key = "texture"
88+
for suffix, key in {
89+
"_metallic": "metallic",
90+
"_normal": "normal",
91+
"_roughness": "roughness",
92+
}.items():
93+
if stem.endswith(suffix):
94+
matched_key = key
95+
break
96+
textures[matched_key] = tensor
97+
if obj_bytes is None:
98+
raise ValueError("ZIP archive does not contain an OBJ file.")
99+
return ObjZipResult(
100+
obj=Types.File3D(source=BytesIO(obj_bytes), file_format="obj"),
101+
texture=textures.get("texture"),
102+
metallic=textures.get("metallic"),
103+
normal=textures.get("normal"),
104+
roughness=textures.get("roughness"),
105+
)
106+
107+
39108
def get_file_from_response(
40109
response_objs: list[ResultFile3D], file_type: str, raise_if_not_found: bool = True
41110
) -> ResultFile3D | None:
@@ -93,6 +162,7 @@ def define_schema(cls):
93162
IO.String.Output(display_name="model_file"), # for backward compatibility only
94163
IO.File3DGLB.Output(display_name="GLB"),
95164
IO.File3DOBJ.Output(display_name="OBJ"),
165+
IO.Image.Output(display_name="texture_image"),
96166
],
97167
hidden=[
98168
IO.Hidden.auth_token_comfy_org,
@@ -151,14 +221,14 @@ async def execute(
151221
response_model=To3DProTaskResultResponse,
152222
status_extractor=lambda r: r.Status,
153223
)
224+
obj_result = await download_and_extract_obj_zip(get_file_from_response(result.ResultFile3Ds, "obj").Url)
154225
return IO.NodeOutput(
155226
f"{task_id}.glb",
156227
await download_url_to_file_3d(
157228
get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb", task_id=task_id
158229
),
159-
await download_url_to_file_3d(
160-
get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj", task_id=task_id
161-
),
230+
obj_result.obj,
231+
obj_result.texture,
162232
)
163233

164234

@@ -211,6 +281,10 @@ def define_schema(cls):
211281
IO.String.Output(display_name="model_file"), # for backward compatibility only
212282
IO.File3DGLB.Output(display_name="GLB"),
213283
IO.File3DOBJ.Output(display_name="OBJ"),
284+
IO.Image.Output(display_name="texture_image"),
285+
IO.Image.Output(display_name="optional_metallic"),
286+
IO.Image.Output(display_name="optional_normal"),
287+
IO.Image.Output(display_name="optional_roughness"),
214288
],
215289
hidden=[
216290
IO.Hidden.auth_token_comfy_org,
@@ -304,14 +378,17 @@ async def execute(
304378
response_model=To3DProTaskResultResponse,
305379
status_extractor=lambda r: r.Status,
306380
)
381+
obj_result = await download_and_extract_obj_zip(get_file_from_response(result.ResultFile3Ds, "obj").Url)
307382
return IO.NodeOutput(
308383
f"{task_id}.glb",
309384
await download_url_to_file_3d(
310385
get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb", task_id=task_id
311386
),
312-
await download_url_to_file_3d(
313-
get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj", task_id=task_id
314-
),
387+
obj_result.obj,
388+
obj_result.texture,
389+
obj_result.metallic if obj_result.metallic is not None else torch.zeros(1, 1, 1, 3),
390+
obj_result.normal if obj_result.normal is not None else torch.zeros(1, 1, 1, 3),
391+
obj_result.roughness if obj_result.roughness is not None else torch.zeros(1, 1, 1, 3),
315392
)
316393

317394

@@ -431,7 +508,8 @@ def define_schema(cls):
431508
],
432509
outputs=[
433510
IO.File3DGLB.Output(display_name="GLB"),
434-
IO.File3DFBX.Output(display_name="FBX"),
511+
IO.File3DOBJ.Output(display_name="OBJ"),
512+
IO.Image.Output(display_name="texture_image"),
435513
],
436514
hidden=[
437515
IO.Hidden.auth_token_comfy_org,
@@ -480,7 +558,8 @@ async def execute(
480558
)
481559
return IO.NodeOutput(
482560
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb"),
483-
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "fbx").Url, "fbx"),
561+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj"),
562+
await download_url_to_image_tensor(get_file_from_response(result.ResultFile3Ds, "texture_image").Url),
484563
)
485564

486565

@@ -654,7 +733,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]:
654733
TencentTextToModelNode,
655734
TencentImageToModelNode,
656735
TencentModelTo3DUVNode,
657-
# Tencent3DTextureEditNode,
736+
Tencent3DTextureEditNode,
658737
Tencent3DPartNode,
659738
TencentSmartTopologyNode,
660739
]

0 commit comments

Comments
 (0)