1+ import zipfile
2+ from io import BytesIO
3+
4+ import torch
15from typing_extensions import override
26
37from comfy_api .latest import IO , ComfyExtension , Input , Types
1721)
1822from 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+
39108def 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