"""Localiza y descarga la malla 3D producida por un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_image, pero para mallas 3D: el nodo SaveGLB de un workflow Hunyuan3D expone su salida en GET /history/{prompt_id} bajo la clave "3d" (no "images"), con {filename, subfolder, type}. Esta funcion lee ese history, localiza el primer archivo de malla (.glb/.obj/.ply/.gltf/.fbx/.stl/.usdz), lo baja via GET /view a disco local y, opcionalmente, lo escribe en `dest`. Impura: red (HTTP GET a /history y /view) + escritura en disco. Solo stdlib. """ import json import os import urllib.error import urllib.parse import urllib.request _MESH_EXTS = (".glb", ".gltf", ".obj", ".ply", ".fbx", ".stl", ".usdz", ".splat") def _find_mesh_output(outputs: dict) -> dict | None: """Busca en los outputs de /history el primer archivo de malla 3D. Recorre cada nodo y cada lista de su output; el SaveGLB usa la clave "3d", pero se acepta cualquier lista de dicts con "filename" de extension de malla. Devuelve {filename, subfolder, type} o None si no hay ninguno. """ # Prioriza la clave canonica "3d"; si no, cualquier lista con filename de malla. for prefer in (True, False): for node_out in outputs.values(): if not isinstance(node_out, dict): continue for key, items in node_out.items(): if prefer and key != "3d": continue if not isinstance(items, list): continue for item in items: if not isinstance(item, dict): continue fn = item.get("filename", "") if fn.lower().endswith(_MESH_EXTS): return { "filename": fn, "subfolder": item.get("subfolder", ""), "type": item.get("type", "output"), } return None def _resolve_dest(dest: str | None, filename: str) -> str: """Resuelve la ruta local destino a partir de `dest` y el basename remoto.""" base = os.path.basename(filename) if dest is None: return os.path.join(os.getcwd(), base) expanded = os.path.expanduser(dest) if os.path.isdir(expanded) or expanded.endswith(os.sep): return os.path.join(expanded, base) return expanded def comfyui_fetch_output_mesh( prompt_id: str, *, server: str = "127.0.0.1:8188", dest: str | None = None, timeout: float = 120.0, ) -> dict: """Descarga la malla 3D de un prompt ComfyUI ya ejecutado a disco local. Args: prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo SaveGLB ya termino (usa comfyui_wait_result antes si dudas). server: host:port del servidor ComfyUI (sin esquema). keyword-only. dest: ruta destino. Si None, escribe el basename de la malla en el cwd. Si es un directorio (o termina en separador), escribe el basename dentro. Si es una ruta de archivo, escribe ahi. keyword-only. timeout: timeout de cada peticion HTTP en segundos. keyword-only. Returns: dict {ok, path, format, bytes, error}. path = ruta local del archivo de malla guardado; format = extension sin punto (ej. "glb"); bytes = tamano descargado. Si falla, ok=False y error explica (sin malla en history, HTTP, conexion o escritura). """ hist_url = f"http://{server}/history/{prompt_id}" try: with urllib.request.urlopen(hist_url, timeout=timeout) as resp: hist = json.loads(resp.read()) except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:200] return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"HTTP {exc.code} en {hist_url}: {body}"} except urllib.error.URLError as exc: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"no se pudo conectar a {hist_url}: {exc.reason}"} except json.JSONDecodeError as exc: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"respuesta no es JSON valido desde {hist_url}: {exc}"} entry = hist.get(prompt_id) if not entry: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"prompt_id {prompt_id} no esta en /history (¿no termino o se purgo?)"} outputs = entry.get("outputs", {}) mesh = _find_mesh_output(outputs) if mesh is None: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"sin archivo de malla 3D en los outputs de {prompt_id}"} qs = urllib.parse.urlencode({ "filename": mesh["filename"], "subfolder": mesh["subfolder"], "type": mesh["type"], }) view_url = f"http://{server}/view?{qs}" try: with urllib.request.urlopen(view_url, timeout=timeout) as resp: blob = resp.read() except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:200] return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"HTTP {exc.code} en {view_url}: {body}"} except urllib.error.URLError as exc: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"no se pudo conectar a {view_url}: {exc.reason}"} out_path = _resolve_dest(dest, mesh["filename"]) try: parent = os.path.dirname(out_path) if parent: os.makedirs(parent, exist_ok=True) with open(out_path, "wb") as f: f.write(blob) except OSError as exc: return {"ok": False, "path": "", "format": "", "bytes": 0, "error": f"no se pudo escribir en {out_path!r}: {exc}"} fmt = os.path.splitext(mesh["filename"])[1].lstrip(".").lower() return {"ok": True, "path": out_path, "format": fmt, "bytes": len(blob), "error": ""} if __name__ == "__main__": import sys pid = sys.argv[1] if len(sys.argv) > 1 else "00000000-0000-0000-0000-000000000000" res = comfyui_fetch_output_mesh(pid, dest="/tmp/comfy_mesh") print(json.dumps(res, indent=2))