mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-01-25 22:00:19 +08:00
* feat(api-nodes): add TencentHunyuan3D nodes * add "(Pro)" to display name --------- Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
298 lines
11 KiB
Python
298 lines
11 KiB
Python
import os
|
|
|
|
from typing_extensions import override
|
|
|
|
from comfy_api.latest import IO, ComfyExtension, Input
|
|
from comfy_api_nodes.apis.hunyuan3d import (
|
|
Hunyuan3DViewImage,
|
|
InputGenerateType,
|
|
ResultFile3D,
|
|
To3DProTaskCreateResponse,
|
|
To3DProTaskQueryRequest,
|
|
To3DProTaskRequest,
|
|
To3DProTaskResultResponse,
|
|
)
|
|
from comfy_api_nodes.util import (
|
|
ApiEndpoint,
|
|
download_url_to_bytesio,
|
|
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:
|
|
for i in response_objs:
|
|
if i.Type.lower() == "glb":
|
|
return i
|
|
raise ValueError("No GLB file found in response. Please report this to the developers.")
|
|
|
|
|
|
class TencentTextToModelNode(IO.ComfyNode):
|
|
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return IO.Schema(
|
|
node_id="TencentTextToModelNode",
|
|
display_name="Hunyuan3D: Text to Model (Pro)",
|
|
category="api node/3d/Tencent",
|
|
inputs=[
|
|
IO.Combo.Input(
|
|
"model",
|
|
options=["3.0", "3.1"],
|
|
tooltip="The LowPoly option is unavailable for the `3.1` model.",
|
|
),
|
|
IO.String.Input("prompt", multiline=True, default="", tooltip="Supports up to 1024 characters."),
|
|
IO.Int.Input("face_count", default=500000, min=40000, max=1500000),
|
|
IO.DynamicCombo.Input(
|
|
"generate_type",
|
|
options=[
|
|
IO.DynamicCombo.Option("Normal", [IO.Boolean.Input("pbr", default=False)]),
|
|
IO.DynamicCombo.Option(
|
|
"LowPoly",
|
|
[
|
|
IO.Combo.Input("polygon_type", options=["triangle", "quadrilateral"]),
|
|
IO.Boolean.Input("pbr", default=False),
|
|
],
|
|
),
|
|
IO.DynamicCombo.Option("Geometry", []),
|
|
],
|
|
),
|
|
IO.Int.Input(
|
|
"seed",
|
|
default=0,
|
|
min=0,
|
|
max=2147483647,
|
|
display_mode=IO.NumberDisplay.number,
|
|
control_after_generate=True,
|
|
tooltip="Seed controls whether the node should re-run; "
|
|
"results are non-deterministic regardless of seed.",
|
|
),
|
|
],
|
|
outputs=[
|
|
IO.String.Output(display_name="model_file"),
|
|
],
|
|
hidden=[
|
|
IO.Hidden.auth_token_comfy_org,
|
|
IO.Hidden.api_key_comfy_org,
|
|
IO.Hidden.unique_id,
|
|
],
|
|
is_api_node=True,
|
|
is_output_node=True,
|
|
price_badge=IO.PriceBadge(
|
|
depends_on=IO.PriceBadgeDepends(widgets=["generate_type", "generate_type.pbr", "face_count"]),
|
|
expr="""
|
|
(
|
|
$base := widgets.generate_type = "normal" ? 25 : widgets.generate_type = "lowpoly" ? 30 : 15;
|
|
$pbr := $lookup(widgets, "generate_type.pbr") ? 10 : 0;
|
|
$face := widgets.face_count != 500000 ? 10 : 0;
|
|
{"type":"usd","usd": ($base + $pbr + $face) * 0.02}
|
|
)
|
|
""",
|
|
),
|
|
)
|
|
|
|
@classmethod
|
|
async def execute(
|
|
cls,
|
|
model: str,
|
|
prompt: str,
|
|
face_count: int,
|
|
generate_type: InputGenerateType,
|
|
seed: int,
|
|
) -> IO.NodeOutput:
|
|
_ = seed
|
|
validate_string(prompt, field_name="prompt", min_length=1, max_length=1024)
|
|
if model == "3.1" and generate_type["generate_type"].lower() == "lowpoly":
|
|
raise ValueError("The LowPoly option is currently unavailable for the 3.1 model.")
|
|
response = await sync_op(
|
|
cls,
|
|
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro", method="POST"),
|
|
response_model=To3DProTaskCreateResponse,
|
|
data=To3DProTaskRequest(
|
|
Model=model,
|
|
Prompt=prompt,
|
|
FaceCount=face_count,
|
|
GenerateType=generate_type["generate_type"],
|
|
EnablePBR=generate_type.get("pbr", None),
|
|
PolygonType=generate_type.get("polygon_type", None),
|
|
),
|
|
)
|
|
if response.Error:
|
|
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
|
|
result = await poll_op(
|
|
cls,
|
|
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
|
|
data=To3DProTaskQueryRequest(JobId=response.JobId),
|
|
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),
|
|
)
|
|
return IO.NodeOutput(model_file)
|
|
|
|
|
|
class TencentImageToModelNode(IO.ComfyNode):
|
|
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return IO.Schema(
|
|
node_id="TencentImageToModelNode",
|
|
display_name="Hunyuan3D: Image(s) to Model (Pro)",
|
|
category="api node/3d/Tencent",
|
|
inputs=[
|
|
IO.Combo.Input(
|
|
"model",
|
|
options=["3.0", "3.1"],
|
|
tooltip="The LowPoly option is unavailable for the `3.1` model.",
|
|
),
|
|
IO.Image.Input("image"),
|
|
IO.Image.Input("image_left", optional=True),
|
|
IO.Image.Input("image_right", optional=True),
|
|
IO.Image.Input("image_back", optional=True),
|
|
IO.Int.Input("face_count", default=500000, min=40000, max=1500000),
|
|
IO.DynamicCombo.Input(
|
|
"generate_type",
|
|
options=[
|
|
IO.DynamicCombo.Option("Normal", [IO.Boolean.Input("pbr", default=False)]),
|
|
IO.DynamicCombo.Option(
|
|
"LowPoly",
|
|
[
|
|
IO.Combo.Input("polygon_type", options=["triangle", "quadrilateral"]),
|
|
IO.Boolean.Input("pbr", default=False),
|
|
],
|
|
),
|
|
IO.DynamicCombo.Option("Geometry", []),
|
|
],
|
|
),
|
|
IO.Int.Input(
|
|
"seed",
|
|
default=0,
|
|
min=0,
|
|
max=2147483647,
|
|
display_mode=IO.NumberDisplay.number,
|
|
control_after_generate=True,
|
|
tooltip="Seed controls whether the node should re-run; "
|
|
"results are non-deterministic regardless of seed.",
|
|
),
|
|
],
|
|
outputs=[
|
|
IO.String.Output(display_name="model_file"),
|
|
],
|
|
hidden=[
|
|
IO.Hidden.auth_token_comfy_org,
|
|
IO.Hidden.api_key_comfy_org,
|
|
IO.Hidden.unique_id,
|
|
],
|
|
is_api_node=True,
|
|
is_output_node=True,
|
|
price_badge=IO.PriceBadge(
|
|
depends_on=IO.PriceBadgeDepends(
|
|
widgets=["generate_type", "generate_type.pbr", "face_count"],
|
|
inputs=["image_left", "image_right", "image_back"],
|
|
),
|
|
expr="""
|
|
(
|
|
$base := widgets.generate_type = "normal" ? 25 : widgets.generate_type = "lowpoly" ? 30 : 15;
|
|
$multiview := (
|
|
inputs.image_left.connected or inputs.image_right.connected or inputs.image_back.connected
|
|
) ? 10 : 0;
|
|
$pbr := $lookup(widgets, "generate_type.pbr") ? 10 : 0;
|
|
$face := widgets.face_count != 500000 ? 10 : 0;
|
|
{"type":"usd","usd": ($base + $multiview + $pbr + $face) * 0.02}
|
|
)
|
|
""",
|
|
),
|
|
)
|
|
|
|
@classmethod
|
|
async def execute(
|
|
cls,
|
|
model: str,
|
|
image: Input.Image,
|
|
face_count: int,
|
|
generate_type: InputGenerateType,
|
|
seed: int,
|
|
image_left: Input.Image | None = None,
|
|
image_right: Input.Image | None = None,
|
|
image_back: Input.Image | None = None,
|
|
) -> IO.NodeOutput:
|
|
_ = seed
|
|
if model == "3.1" and generate_type["generate_type"].lower() == "lowpoly":
|
|
raise ValueError("The LowPoly option is currently unavailable for the 3.1 model.")
|
|
validate_image_dimensions(image, min_width=128, min_height=128)
|
|
multiview_images = []
|
|
for k, v in {
|
|
"left": image_left,
|
|
"right": image_right,
|
|
"back": image_back,
|
|
}.items():
|
|
if v is None:
|
|
continue
|
|
validate_image_dimensions(v, min_width=128, min_height=128)
|
|
multiview_images.append(
|
|
Hunyuan3DViewImage(
|
|
ViewType=k,
|
|
ViewImageUrl=await upload_image_to_comfyapi(
|
|
cls,
|
|
downscale_image_tensor_by_max_side(v, max_side=4900),
|
|
mime_type="image/webp",
|
|
total_pixels=24_010_000,
|
|
),
|
|
)
|
|
)
|
|
response = await sync_op(
|
|
cls,
|
|
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro", method="POST"),
|
|
response_model=To3DProTaskCreateResponse,
|
|
data=To3DProTaskRequest(
|
|
Model=model,
|
|
FaceCount=face_count,
|
|
GenerateType=generate_type["generate_type"],
|
|
ImageUrl=await upload_image_to_comfyapi(
|
|
cls,
|
|
downscale_image_tensor_by_max_side(image, max_side=4900),
|
|
mime_type="image/webp",
|
|
total_pixels=24_010_000,
|
|
),
|
|
MultiViewImages=multiview_images if multiview_images else None,
|
|
EnablePBR=generate_type.get("pbr", None),
|
|
PolygonType=generate_type.get("polygon_type", None),
|
|
),
|
|
)
|
|
if response.Error:
|
|
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
|
|
result = await poll_op(
|
|
cls,
|
|
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
|
|
data=To3DProTaskQueryRequest(JobId=response.JobId),
|
|
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),
|
|
)
|
|
return IO.NodeOutput(model_file)
|
|
|
|
|
|
class TencentHunyuan3DExtension(ComfyExtension):
|
|
@override
|
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
|
return [
|
|
TencentTextToModelNode,
|
|
TencentImageToModelNode,
|
|
]
|
|
|
|
|
|
async def comfy_entrypoint() -> TencentHunyuan3DExtension:
|
|
return TencentHunyuan3DExtension()
|