$ cat node-template.py

3D Model Creation Legacy

// Generates a 3D model (GLB) from a single image using Hunyuan3D 2.1. Creates mesh, applies textures via multi-view generation, and exports a ready-to-use GLB file.

Process
3D
template.py
1import os2import sys3import json4import time5import random6import traceback78try:9    import requests10except ImportError:11    import subprocess12    subprocess.check_call([sys.executable, "-m", "pip", "install", "requests"])13    import requests1415COMFYUI_API_URL = os.getenv("COMFYUI_API_URL", "http://192.168.1.39:8188")16INPUT_DIR = "/data/input"17OUTPUT_DIR = "/data/output"1819# ---------- Workflow ----------20WORKFLOW = {21    "4": {22        "inputs": {"model_name": "Hunyuan3D-vae-v2-1-fp16.ckpt"},23        "class_type": "Hy3D21VAELoader",24        "_meta": {"title": "Hunyuan 3D 2.1 VAE Loader"},25    },26    "9": {27        "inputs": {28            "box_v": 1.01,29            "octree_resolution": 256,30            "num_chunks": 64000,31            "mc_level": 0,32            "mc_algo": "mc",33            "enable_flash_vdm": True,34            "force_offload": False,35            "vae": ["4", 0],36            "latents": ["37", 0],37        },38        "class_type": "Hy3D21VAEDecode",39        "_meta": {"title": "Hunyuan 3D 2.1 VAE Decoder"},40    },41    "14": {42        "inputs": {"image": "Flux2-Klein_00273_-Photoroom.png"},43        "class_type": "Hy3D21LoadImageWithTransparency",44        "_meta": {"title": "Hunyuan 3D 2.1 Load Image with Transparency"},45    },46    "19": {47        "inputs": {48            "camera_azimuths": "0, 90, 180, 270, 0, 180",49            "camera_elevations": "0, 0, 0, 0, 90, -90",50            "view_weights": "1, 0.5, 1, 0.5, 1, 1",51            "ortho_scale": 1.1000000000000003,52        },53        "class_type": "Hy3D21CameraConfig",54        "_meta": {"title": "Hunyuan 3D 2.1 Camera Config"},55    },56    "20": {57        "inputs": {58            "view_size": 768,59            "steps": 10,60            "guidance_scale": 3,61            "texture_size": 1024,62            "unwrap_mesh": False,63            "seed": 172988371457815,64            "trimesh": ["45", 0],65            "camera_config": ["19", 0],66            "image": ["14", 2],67        },68        "class_type": "Hy3DMultiViewsGenerator",69        "_meta": {"title": "Hunyuan 3D 2.1 MultiViews Generator"},70    },71    "21": {72        "inputs": {73            "pipeline": ["20", 0],74            "camera_config": ["19", 0],75            "albedo": ["20", 1],76            "mr": ["20", 2],77        },78        "class_type": "Hy3DBakeMultiViews",79        "_meta": {"title": "Hunyuan 3D 2.1 Bake MultiViews"},80    },81    "30": {82        "inputs": {"value": 200000},83        "class_type": "INTConstant",84        "_meta": {"title": "INT Constant"},85    },86    "32": {87        "inputs": {"string": "Hy21_Mesh"},88        "class_type": "StringConstant",89        "_meta": {"title": "String Constant"},90    },91    "37": {92        "inputs": {93            "model": "hunyuan3d-dit-v2-1-fp16.ckpt",94            "steps": 25,95            "guidance_scale": 7.5,96            "seed": 739367420076026,97            "attention_mode": "sdpa",98            "image": ["14", 2],99        },100        "class_type": "Hy3DMeshGenerator",101        "_meta": {"title": "Hunyuan 3D 2.1 Mesh Generator"},102    },103    "43": {104        "inputs": {105            "remove_floaters": True,106            "remove_degenerate_faces": True,107            "reduce_faces": True,108            "max_facenum": ["30", 0],109            "smooth_normals": False,110            "trimesh": ["9", 0],111        },112        "class_type": "Hy3D21PostprocessMesh",113        "_meta": {"title": "Hunyuan 3D 2.1 Post Process Trimesh"},114    },115    "44": {116        "inputs": {117            "filename_prefix": ["32", 0],118            "file_format": "glb",119            "save_file": True,120            "trimesh": ["43", 0],121        },122        "class_type": "Hy3D21ExportMesh",123        "_meta": {"title": "Hunyuan 3D 2.1 Export Mesh"},124    },125    "45": {126        "inputs": {"trimesh": ["43", 0]},127        "class_type": "Hy3D21MeshUVWrap",128        "_meta": {"title": "Hunyuan 3D 2.1 Mesh UV Wrap"},129    },130    "49": {131        "inputs": {132            "output_mesh_name": ["32", 0],133            "pipeline": ["21", 0],134            "albedo": ["21", 1],135            "albedo_mask": ["21", 2],136            "mr": ["21", 3],137            "mr_mask": ["21", 4],138        },139        "class_type": "Hy3DInPaint",140        "_meta": {"title": "Hunyuan 3D 2.1 InPaint"},141    },142    "27": {143        "inputs": {"model_file": ["49", 3], "image": ""},144        "class_type": "Preview3D",145        "_meta": {"title": "Preview 3D & Animation"},146    },147    "46": {148        "inputs": {"filename_prefix": "MV", "images": ["20", 1]},149        "class_type": "SaveImage",150        "_meta": {"title": "Save Image"},151    },152    "50": {153        "inputs": {"images": ["49", 0]},154        "class_type": "PreviewImage",155        "_meta": {"title": "Preview Image"},156    },157    "51": {158        "inputs": {"images": ["49", 1]},159        "class_type": "PreviewImage",160        "_meta": {"title": "Preview Image"},161    },162    "57": {163        "inputs": {164            "filename_prefix": "3D/emblema-model",165            "file_format": "glb",166            "save_file": True,167            "trimesh": ["49", 2],168        },169        "class_type": "Hy3D21ExportMesh",170        "_meta": {"title": "Hunyuan 3D 2.1 Export Mesh"},171    },172}173174175def upload_file_to_comfyui(local_path: str, content_type: str) -> str:176    """Upload a local file to ComfyUI and return the uploaded filename."""177    with open(local_path, "rb") as f:178        resp = requests.post(179            f"{COMFYUI_API_URL}/upload/image",180            files={"image": (os.path.basename(local_path), f, content_type)},181            timeout=30,182        )183    resp.raise_for_status()184    data = resp.json()185    return data["name"]186187188def build_workflow(image_name: str, steps: int, guidance_scale: float, max_faces: int, texture_size: int) -> dict:189    """Build a 3D model generation workflow with the given parameters."""190    import copy191192    wf = copy.deepcopy(WORKFLOW)193194    # Image (node 14): set uploaded filename on the Hy3D21LoadImageWithTransparency node195    wf["14"]["inputs"]["image"] = image_name196197    # Mesh generation (node 37): steps, guidance_scale, random seed198    wf["37"]["inputs"]["steps"] = int(steps)199    wf["37"]["inputs"]["guidance_scale"] = float(guidance_scale)200    wf["37"]["inputs"]["seed"] = random.randint(0, 2**31 - 1)201202    # Max faces (node 30): polygon count203    wf["30"]["inputs"]["value"] = int(max_faces)204205    # Multi-view texture (node 20): texture_size, random seed206    wf["20"]["inputs"]["texture_size"] = int(texture_size)207    wf["20"]["inputs"]["seed"] = random.randint(0, 2**31 - 1)208209    # Output prefix (node 32 + node 57)210    output_prefix = "emblema-3d-model"211    wf["32"]["inputs"]["string"] = output_prefix212    wf["57"]["inputs"]["filename_prefix"] = f"3D/{output_prefix}"213214    return wf215216217def submit_prompt(workflow: dict) -> str:218    """Submit workflow to ComfyUI and return prompt_id."""219    resp = requests.post(220        f"{COMFYUI_API_URL}/prompt",221        json={"prompt": workflow},222        timeout=30,223    )224    if resp.status_code != 200:225        try:226            error_detail = resp.json()227        except Exception:228            error_detail = resp.text229        raise RuntimeError(230            f"ComfyUI /prompt returned {resp.status_code}: {json.dumps(error_detail, indent=2) if isinstance(error_detail, dict) else error_detail}"231        )232    data = resp.json()233234    # ComfyUI returns 200 even when nodes have validation errors235    node_errors = data.get("node_errors", {})236    if node_errors:237        raise RuntimeError(238            f"ComfyUI workflow has node errors: {json.dumps(node_errors, indent=2)}"239        )240241    return data["prompt_id"]242243244def wait_for_result(prompt_id: str, timeout: int = 900, poll_interval: int = 5) -> dict:245    """Poll ComfyUI history until the prompt completes with outputs."""246    deadline = time.time() + timeout247    empty_complete_retries = 0248    max_empty_retries = 3  # grace period for output serialization lag249250    while time.time() < deadline:251        try:252            resp = requests.get(253                f"{COMFYUI_API_URL}/history/{prompt_id}",254                timeout=(10, 30),255            )256            resp.raise_for_status()257            history = resp.json()258        except requests.exceptions.RequestException:259            time.sleep(poll_interval)260            continue261262        if prompt_id in history:263            prompt_data = history[prompt_id]264            status = prompt_data.get("status", {})265266            if status.get("status_str") == "error":267                messages = status.get("messages", [])268                raise RuntimeError(269                    f"ComfyUI prompt failed: {json.dumps(messages, indent=2)}"270                )271272            if status.get("completed", False):273                if prompt_data.get("outputs"):274                    return prompt_data275276                # Completed but no outputs -- retry briefly for race condition277                empty_complete_retries += 1278                if empty_complete_retries >= max_empty_retries:279                    raise RuntimeError(280                        f"ComfyUI prompt completed but produced no outputs. "281                        f"This usually means a node failed silently (missing custom node or model). "282                        f"Status: {json.dumps(status, indent=2)}"283                    )284285        time.sleep(poll_interval)286287    raise TimeoutError(f"ComfyUI prompt {prompt_id} did not complete within {timeout}s")288289290def download_output_model(prompt_data: dict, output_dir: str) -> str:291    """Download the generated GLB model from ComfyUI."""292    outputs = prompt_data.get("outputs", {})293294    # First: check "result" arrays for plain-string GLB filenames (e.g. Preview3D node)295    for node_id, node_output in outputs.items():296        result_list = node_output.get("result")297        if not result_list or not isinstance(result_list, list):298            continue299        for item in result_list:300            if isinstance(item, str) and item.lower().endswith(".glb"):301                resp = requests.get(302                    f"{COMFYUI_API_URL}/view",303                    params={"filename": item, "subfolder": "", "type": "output"},304                    timeout=120,305                )306                resp.raise_for_status()307308                out_filename = f"generated_{item}"309                out_path = os.path.join(output_dir, out_filename)310                with open(out_path, "wb") as f:311                    f.write(resp.content)312                return out_filename313314    # Known output keys that may contain 3D model files315    known_keys = ["meshes", "gifs", "videos", "images", "files", "3d", "mesh"]316317    for node_id, node_output in outputs.items():318        for key in known_keys:319            file_list = node_output.get(key)320            if not file_list or not isinstance(file_list, list):321                continue322323            for item in file_list:324                if not isinstance(item, dict):325                    continue326                filename = item.get("filename", "")327                if filename.lower().endswith(".glb"):328                    subfolder = item.get("subfolder", "")329                    file_type = item.get("type", "output")330331                    resp = requests.get(332                        f"{COMFYUI_API_URL}/view",333                        params={334                            "filename": filename,335                            "subfolder": subfolder,336                            "type": file_type,337                        },338                        timeout=120,339                    )340                    resp.raise_for_status()341342                    out_filename = f"generated_{filename}"343                    out_path = os.path.join(output_dir, out_filename)344                    with open(out_path, "wb") as f:345                        f.write(resp.content)346347                    return out_filename348349    # Fallback: scan ALL values in all node outputs for .glb files350    for node_id, node_output in outputs.items():351        for key, value in node_output.items():352            if not isinstance(value, list):353                continue354            for item in value:355                if not isinstance(item, dict):356                    continue357                filename = item.get("filename", "")358                if filename.lower().endswith(".glb"):359                    subfolder = item.get("subfolder", "")360                    file_type = item.get("type", "output")361362                    resp = requests.get(363                        f"{COMFYUI_API_URL}/view",364                        params={365                            "filename": filename,366                            "subfolder": subfolder,367                            "type": file_type,368                        },369                        timeout=120,370                    )371                    resp.raise_for_status()372373                    out_filename = f"generated_{filename}"374                    out_path = os.path.join(output_dir, out_filename)375                    with open(out_path, "wb") as f:376                        f.write(resp.content)377378                    return out_filename379380    raise RuntimeError(381        f"No GLB model found in ComfyUI response. Available outputs: {json.dumps(outputs, indent=2)}"382    )383384385def main():386    try:387        input_json = sys.stdin.read()388        execution_input = json.loads(input_json)389        inputs = execution_input.get("inputs", {})390391        image = inputs.get("image", "")392        steps = inputs.get("steps", 25)393        guidance_scale = inputs.get("guidance_scale", 7.5)394        max_faces = inputs.get("max_faces", 200000)395        texture_size = inputs.get("texture_size", "1024")396397        if not image:398            raise ValueError("Image input is required")399400        os.makedirs(OUTPUT_DIR, exist_ok=True)401402        # Upload image to ComfyUI403        image_path = os.path.join(INPUT_DIR, image)404        if not os.path.exists(image_path):405            raise FileNotFoundError(f"Input image not found: {image_path}")406        comfyui_image_name = upload_file_to_comfyui(image_path, "image/png")407408        # Build workflow, submit, wait, download409        workflow = build_workflow(410            comfyui_image_name, steps, guidance_scale, max_faces, int(texture_size),411        )412        prompt_id = submit_prompt(workflow)413        prompt_data = wait_for_result(prompt_id)414        out_filename = download_output_model(prompt_data, OUTPUT_DIR)415416        # Log metadata to stderr417        print(418            f"prompt_id={prompt_id}, steps={steps}, guidance_scale={guidance_scale}, "419            f"max_faces={max_faces}, texture_size={texture_size}",420            file=sys.stderr,421        )422423        # Flat output -- keys match OUTPUT_SCHEMA424        output = {425            "model": out_filename,426        }427        print(json.dumps(output, indent=2))428429    except Exception as e:430        error_output = {431            "error": str(e),432            "errorType": type(e).__name__,433            "traceback": traceback.format_exc(),434        }435        print(json.dumps(error_output), file=sys.stderr)436        sys.exit(1)437438439if __name__ == "__main__":440    main()