Skip to content

Commit e2c71ce

Browse files
authored
feat(api-nodes-Tencent): add ModelTo3DUV, 3DTextureEdit, 3DParts nodes (Comfy-Org#12428)
* feat(api-nodes-Tencent): add ModelTo3DUV, 3DTextureEdit, 3DParts nodes * add image output to TencentModelTo3DUV node * commented out two nodes * added rate_limit check to other hunyuan3d nodes
1 parent 596ed68 commit e2c71ce

File tree

4 files changed

+326
-12
lines changed

4 files changed

+326
-12
lines changed

comfy_api_nodes/apis/hunyuan3d.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,23 @@ def unwrap_data(cls, values: dict) -> dict:
6464

6565
class To3DProTaskQueryRequest(BaseModel):
6666
JobId: str = Field(...)
67+
68+
69+
class To3DUVFileInput(BaseModel):
70+
Type: str = Field(..., description="File type: GLB, OBJ, or FBX")
71+
Url: str = Field(...)
72+
73+
74+
class To3DUVTaskRequest(BaseModel):
75+
File: To3DUVFileInput = Field(...)
76+
77+
78+
class TextureEditImageInfo(BaseModel):
79+
Url: str = Field(...)
80+
81+
82+
class TextureEditTaskRequest(BaseModel):
83+
File3D: To3DUVFileInput = Field(...)
84+
Image: TextureEditImageInfo | None = Field(None)
85+
Prompt: str | None = Field(None)
86+
EnablePBR: bool | None = Field(None)

comfy_api_nodes/nodes_hunyuan3d.py

Lines changed: 283 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,48 @@
11
from typing_extensions import override
22

3-
from comfy_api.latest import IO, ComfyExtension, Input
3+
from comfy_api.latest import IO, ComfyExtension, Input, Types
44
from comfy_api_nodes.apis.hunyuan3d import (
55
Hunyuan3DViewImage,
66
InputGenerateType,
77
ResultFile3D,
8+
TextureEditTaskRequest,
89
To3DProTaskCreateResponse,
910
To3DProTaskQueryRequest,
1011
To3DProTaskRequest,
1112
To3DProTaskResultResponse,
13+
To3DUVFileInput,
14+
To3DUVTaskRequest,
1215
)
1316
from comfy_api_nodes.util import (
1417
ApiEndpoint,
1518
download_url_to_file_3d,
19+
download_url_to_image_tensor,
1620
downscale_image_tensor_by_max_side,
1721
poll_op,
1822
sync_op,
23+
upload_3d_model_to_comfyapi,
1924
upload_image_to_comfyapi,
2025
validate_image_dimensions,
2126
validate_string,
2227
)
2328

2429

25-
def get_file_from_response(response_objs: list[ResultFile3D], file_type: str) -> ResultFile3D | None:
30+
def _is_tencent_rate_limited(status: int, body: object) -> bool:
31+
return (
32+
status == 400
33+
and isinstance(body, dict)
34+
and "RequestLimitExceeded" in str(body.get("Response", {}).get("Error", {}).get("Code", ""))
35+
)
36+
37+
38+
def get_file_from_response(
39+
response_objs: list[ResultFile3D], file_type: str, raise_if_not_found: bool = True
40+
) -> ResultFile3D | None:
2641
for i in response_objs:
2742
if i.Type.lower() == file_type.lower():
2843
return i
44+
if raise_if_not_found:
45+
raise ValueError(f"'{file_type}' file type is not found in the response.")
2946
return None
3047

3148

@@ -35,7 +52,7 @@ class TencentTextToModelNode(IO.ComfyNode):
3552
def define_schema(cls):
3653
return IO.Schema(
3754
node_id="TencentTextToModelNode",
38-
display_name="Hunyuan3D: Text to Model (Pro)",
55+
display_name="Hunyuan3D: Text to Model",
3956
category="api node/3d/Tencent",
4057
inputs=[
4158
IO.Combo.Input(
@@ -120,6 +137,7 @@ async def execute(
120137
EnablePBR=generate_type.get("pbr", None),
121138
PolygonType=generate_type.get("polygon_type", None),
122139
),
140+
is_rate_limited=_is_tencent_rate_limited,
123141
)
124142
if response.Error:
125143
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
@@ -131,11 +149,14 @@ async def execute(
131149
response_model=To3DProTaskResultResponse,
132150
status_extractor=lambda r: r.Status,
133151
)
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
137152
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
153+
f"{task_id}.glb",
154+
await download_url_to_file_3d(
155+
get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb", task_id=task_id
156+
),
157+
await download_url_to_file_3d(
158+
get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj", task_id=task_id
159+
),
139160
)
140161

141162

@@ -145,7 +166,7 @@ class TencentImageToModelNode(IO.ComfyNode):
145166
def define_schema(cls):
146167
return IO.Schema(
147168
node_id="TencentImageToModelNode",
148-
display_name="Hunyuan3D: Image(s) to Model (Pro)",
169+
display_name="Hunyuan3D: Image(s) to Model",
149170
category="api node/3d/Tencent",
150171
inputs=[
151172
IO.Combo.Input(
@@ -268,6 +289,7 @@ async def execute(
268289
EnablePBR=generate_type.get("pbr", None),
269290
PolygonType=generate_type.get("polygon_type", None),
270291
),
292+
is_rate_limited=_is_tencent_rate_limited,
271293
)
272294
if response.Error:
273295
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
@@ -279,11 +301,257 @@ async def execute(
279301
response_model=To3DProTaskResultResponse,
280302
status_extractor=lambda r: r.Status,
281303
)
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
285304
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
305+
f"{task_id}.glb",
306+
await download_url_to_file_3d(
307+
get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb", task_id=task_id
308+
),
309+
await download_url_to_file_3d(
310+
get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj", task_id=task_id
311+
),
312+
)
313+
314+
315+
class TencentModelTo3DUVNode(IO.ComfyNode):
316+
317+
@classmethod
318+
def define_schema(cls):
319+
return IO.Schema(
320+
node_id="TencentModelTo3DUVNode",
321+
display_name="Hunyuan3D: Model to UV",
322+
category="api node/3d/Tencent",
323+
description="Perform UV unfolding on a 3D model to generate UV texture. "
324+
"Input model must have less than 30000 faces.",
325+
inputs=[
326+
IO.MultiType.Input(
327+
"model_3d",
328+
types=[IO.File3DGLB, IO.File3DOBJ, IO.File3DFBX, IO.File3DAny],
329+
tooltip="Input 3D model (GLB, OBJ, or FBX)",
330+
),
331+
IO.Int.Input(
332+
"seed",
333+
default=1,
334+
min=0,
335+
max=2147483647,
336+
display_mode=IO.NumberDisplay.number,
337+
control_after_generate=True,
338+
tooltip="Seed controls whether the node should re-run; "
339+
"results are non-deterministic regardless of seed.",
340+
),
341+
],
342+
outputs=[
343+
IO.File3DOBJ.Output(display_name="OBJ"),
344+
IO.File3DFBX.Output(display_name="FBX"),
345+
IO.Image.Output(),
346+
],
347+
hidden=[
348+
IO.Hidden.auth_token_comfy_org,
349+
IO.Hidden.api_key_comfy_org,
350+
IO.Hidden.unique_id,
351+
],
352+
is_api_node=True,
353+
price_badge=IO.PriceBadge(expr='{"type":"usd","usd":0.2}'),
354+
)
355+
356+
SUPPORTED_FORMATS = {"glb", "obj", "fbx"}
357+
358+
@classmethod
359+
async def execute(
360+
cls,
361+
model_3d: Types.File3D,
362+
seed: int,
363+
) -> IO.NodeOutput:
364+
_ = seed
365+
file_format = model_3d.format.lower()
366+
if file_format not in cls.SUPPORTED_FORMATS:
367+
raise ValueError(
368+
f"Unsupported file format: '{file_format}'. "
369+
f"Supported formats: {', '.join(sorted(cls.SUPPORTED_FORMATS))}."
370+
)
371+
response = await sync_op(
372+
cls,
373+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-uv", method="POST"),
374+
response_model=To3DProTaskCreateResponse,
375+
data=To3DUVTaskRequest(
376+
File=To3DUVFileInput(
377+
Type=file_format.upper(),
378+
Url=await upload_3d_model_to_comfyapi(cls, model_3d, file_format),
379+
)
380+
),
381+
is_rate_limited=_is_tencent_rate_limited,
382+
)
383+
if response.Error:
384+
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
385+
result = await poll_op(
386+
cls,
387+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-uv/query", method="POST"),
388+
data=To3DProTaskQueryRequest(JobId=response.JobId),
389+
response_model=To3DProTaskResultResponse,
390+
status_extractor=lambda r: r.Status,
391+
)
392+
return IO.NodeOutput(
393+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj"),
394+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "fbx").Url, "fbx"),
395+
await download_url_to_image_tensor(get_file_from_response(result.ResultFile3Ds, "image").Url),
396+
)
397+
398+
399+
class Tencent3DTextureEditNode(IO.ComfyNode):
400+
401+
@classmethod
402+
def define_schema(cls):
403+
return IO.Schema(
404+
node_id="Tencent3DTextureEditNode",
405+
display_name="Hunyuan3D: 3D Texture Edit",
406+
category="api node/3d/Tencent",
407+
description="After inputting the 3D model, perform 3D model texture redrawing.",
408+
inputs=[
409+
IO.MultiType.Input(
410+
"model_3d",
411+
types=[IO.File3DFBX, IO.File3DAny],
412+
tooltip="3D model in FBX format. Model should have less than 100000 faces.",
413+
),
414+
IO.String.Input(
415+
"prompt",
416+
multiline=True,
417+
default="",
418+
tooltip="Describes texture editing. Supports up to 1024 UTF-8 characters.",
419+
),
420+
IO.Int.Input(
421+
"seed",
422+
default=0,
423+
min=0,
424+
max=2147483647,
425+
display_mode=IO.NumberDisplay.number,
426+
control_after_generate=True,
427+
tooltip="Seed controls whether the node should re-run; "
428+
"results are non-deterministic regardless of seed.",
429+
),
430+
],
431+
outputs=[
432+
IO.File3DGLB.Output(display_name="GLB"),
433+
IO.File3DFBX.Output(display_name="FBX"),
434+
],
435+
hidden=[
436+
IO.Hidden.auth_token_comfy_org,
437+
IO.Hidden.api_key_comfy_org,
438+
IO.Hidden.unique_id,
439+
],
440+
is_api_node=True,
441+
price_badge=IO.PriceBadge(
442+
expr="""{"type":"usd","usd": 0.6}""",
443+
),
444+
)
445+
446+
@classmethod
447+
async def execute(
448+
cls,
449+
model_3d: Types.File3D,
450+
prompt: str,
451+
seed: int,
452+
) -> IO.NodeOutput:
453+
_ = seed
454+
file_format = model_3d.format.lower()
455+
if file_format != "fbx":
456+
raise ValueError(f"Unsupported file format: '{file_format}'. Only FBX format is supported.")
457+
validate_string(prompt, field_name="prompt", min_length=1, max_length=1024)
458+
model_url = await upload_3d_model_to_comfyapi(cls, model_3d, file_format)
459+
response = await sync_op(
460+
cls,
461+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-texture-edit", method="POST"),
462+
response_model=To3DProTaskCreateResponse,
463+
data=TextureEditTaskRequest(
464+
File3D=To3DUVFileInput(Type=file_format.upper(), Url=model_url),
465+
Prompt=prompt,
466+
EnablePBR=True,
467+
),
468+
is_rate_limited=_is_tencent_rate_limited,
469+
)
470+
if response.Error:
471+
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
472+
473+
result = await poll_op(
474+
cls,
475+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-texture-edit/query", method="POST"),
476+
data=To3DProTaskQueryRequest(JobId=response.JobId),
477+
response_model=To3DProTaskResultResponse,
478+
status_extractor=lambda r: r.Status,
479+
)
480+
return IO.NodeOutput(
481+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "glb").Url, "glb"),
482+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "fbx").Url, "fbx"),
483+
)
484+
485+
486+
class Tencent3DPartNode(IO.ComfyNode):
487+
488+
@classmethod
489+
def define_schema(cls):
490+
return IO.Schema(
491+
node_id="Tencent3DPartNode",
492+
display_name="Hunyuan3D: 3D Part",
493+
category="api node/3d/Tencent",
494+
description="Automatically perform component identification and generation based on the model structure.",
495+
inputs=[
496+
IO.MultiType.Input(
497+
"model_3d",
498+
types=[IO.File3DFBX, IO.File3DAny],
499+
tooltip="3D model in FBX format. Model should have less than 30000 faces.",
500+
),
501+
IO.Int.Input(
502+
"seed",
503+
default=0,
504+
min=0,
505+
max=2147483647,
506+
display_mode=IO.NumberDisplay.number,
507+
control_after_generate=True,
508+
tooltip="Seed controls whether the node should re-run; "
509+
"results are non-deterministic regardless of seed.",
510+
),
511+
],
512+
outputs=[
513+
IO.File3DFBX.Output(display_name="FBX"),
514+
],
515+
hidden=[
516+
IO.Hidden.auth_token_comfy_org,
517+
IO.Hidden.api_key_comfy_org,
518+
IO.Hidden.unique_id,
519+
],
520+
is_api_node=True,
521+
price_badge=IO.PriceBadge(expr='{"type":"usd","usd":0.6}'),
522+
)
523+
524+
@classmethod
525+
async def execute(
526+
cls,
527+
model_3d: Types.File3D,
528+
seed: int,
529+
) -> IO.NodeOutput:
530+
_ = seed
531+
file_format = model_3d.format.lower()
532+
if file_format != "fbx":
533+
raise ValueError(f"Unsupported file format: '{file_format}'. Only FBX format is supported.")
534+
model_url = await upload_3d_model_to_comfyapi(cls, model_3d, file_format)
535+
response = await sync_op(
536+
cls,
537+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-part", method="POST"),
538+
response_model=To3DProTaskCreateResponse,
539+
data=To3DUVTaskRequest(
540+
File=To3DUVFileInput(Type=file_format.upper(), Url=model_url),
541+
),
542+
is_rate_limited=_is_tencent_rate_limited,
543+
)
544+
if response.Error:
545+
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
546+
result = await poll_op(
547+
cls,
548+
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-part/query", method="POST"),
549+
data=To3DProTaskQueryRequest(JobId=response.JobId),
550+
response_model=To3DProTaskResultResponse,
551+
status_extractor=lambda r: r.Status,
552+
)
553+
return IO.NodeOutput(
554+
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "fbx").Url, "fbx"),
287555
)
288556

289557

@@ -293,6 +561,9 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]:
293561
return [
294562
TencentTextToModelNode,
295563
TencentImageToModelNode,
564+
# TencentModelTo3DUVNode,
565+
# Tencent3DTextureEditNode,
566+
Tencent3DPartNode,
296567
]
297568

298569

0 commit comments

Comments
 (0)