"""Descarga un workflow ComfyUI desde CUALQUIER fuente y lo normaliza a API format. Dispatcher: detecta el tipo de fuente por la URL/patron y delega la descarga, luego normaliza el resultado a API format reusando las dos funciones de import del registry (no reescribe la conversion): - Google Drive (drive.google.com/.../d/ o uc?id=) -> gdown (si esta) o descarga directa uc?export=download -> import_workflow_json | import_workflow_png - GitHub (github.com/.../blob/... o raw.githubusercontent.com) -> raw URL del .json/.png -> import_workflow_json | import_workflow_png - Civitai (civitai.com/api/download/... o pagina /models/) -> resuelve el downloadUrl via API REST, descarga el archivo (zip o json) -> import - HuggingFace (huggingface.co/datasets/.../resolve/...) -> import_workflow_json - URL directa .json/.png/.webp o path local -> import segun extension El resultado SIEMPRE es API format (dict {node_id: {class_type, inputs}}), listo para comfyui_validate_workflow + comfyui_submit_workflow. Compone comfyui_import_workflow_json + comfyui_import_workflow_png. Impura: red (HTTP GET / gdown), descompresion de zip y lectura/escritura de disco. Solo stdlib (urllib, json, zipfile, tempfile, re) + gdown opcional para Drive. """ import json import os import re import sys import tempfile import urllib.error import urllib.parse import urllib.request import zipfile _THIS_DIR = os.path.dirname(os.path.abspath(__file__)) if _THIS_DIR not in sys.path: sys.path.insert(0, _THIS_DIR) from comfyui_import_workflow_json import comfyui_import_workflow_json # noqa: E402 from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402 _UA = "Mozilla/5.0 (fn_registry comfyui_download_workflow)" def comfyui_download_workflow( source: str, dest: str | None = None, *, server: str = "127.0.0.1:8188", civitai_token: str | None = None, hf_token: str | None = None, timeout: float = 30.0, ) -> dict: """Descarga un workflow de ComfyUI de cualquier fuente y lo normaliza a API format. Args: source: URL (Google Drive, GitHub, Civitai, HuggingFace, o directa a .json/.png/.webp) o ruta de un archivo local. dest: ruta local donde guardar el archivo descargado. Si None, se usa un archivo temporal (que se conserva para trazabilidad y se reporta en 'path'). Para fuentes locales no se copia: 'path' = source. server: host:port de ComfyUI, usado SOLO para mapear widgets cuando la fuente viene en formato UI graph (lo pasa a import_workflow_json). keyword-only. civitai_token: token de Civitai (Bearer) para descargas restringidas/gated. keyword-only. hf_token: token de HuggingFace (Bearer) para datasets privados. keyword-only. timeout: timeout HTTP en segundos. keyword-only. Returns: dict {ok, workflow, source_type, path, format_in, error}: - workflow: dict en API format (vacio si ok=False). - source_type: 'drive' | 'github' | 'civitai' | 'huggingface' | 'direct' | 'local'. - path: ruta local del archivo descargado (o source si era local). - format_in: formato de origen detectado ('api', 'ui_graph', 'png-prompt', 'png-workflow', 'zip'). Nunca lanza: cualquier fallo de red/IO devuelve ok=False con error. """ source_type = _detect_source_type(source) try: if source_type == "local": local_path = source if not os.path.exists(local_path): return _err(source_type, f"no existe el archivo local {source!r}") elif source_type == "drive": local_path = _download_drive(source, dest, timeout) elif source_type == "civitai": local_path = _download_civitai(source, dest, civitai_token, timeout) else: # github | huggingface | direct url = _to_raw_url(source) if source_type == "github" else source token = hf_token if source_type == "huggingface" else None local_path = _download_url(url, dest, token, timeout) except _DownloadError as exc: return _err(source_type, str(exc)) except (urllib.error.URLError, OSError) as exc: return _err(source_type, f"fallo de descarga: {exc}") # Si bajamos un zip (tipico de Civitai), extraer el primer workflow de dentro. if local_path.lower().endswith(".zip"): try: inner, fmt_hint = _extract_from_zip(local_path) except _DownloadError as exc: return _err(source_type, str(exc), path=local_path, fmt="zip") norm = _normalize(inner, server, timeout) norm["format_in"] = "zip" norm["source_type"] = source_type norm["path"] = local_path return norm norm = _normalize(local_path, server, timeout) norm["source_type"] = source_type norm["path"] = local_path return norm # --------------------------------------------------------------------------- # # Deteccion + resolucion de URLs # --------------------------------------------------------------------------- # def _detect_source_type(source: str) -> str: if not source.startswith(("http://", "https://")): return "local" host = urllib.parse.urlparse(source).netloc.lower() if "drive.google.com" in host or "docs.google.com" in host: return "drive" if "civitai.com" in host: return "civitai" if "github.com" in host or "githubusercontent.com" in host: return "github" if "huggingface.co" in host: return "huggingface" return "direct" def _to_raw_url(github_url: str) -> str: """Convierte una URL github.com/.../blob// a raw.githubusercontent.com.""" if "raw.githubusercontent.com" in github_url or "/raw/" in github_url: return github_url m = re.match( r"https://github\.com/([^/]+)/([^/]+)/blob/(.+)$", github_url ) if m: user, repo, rest = m.groups() return f"https://raw.githubusercontent.com/{user}/{repo}/{rest}" return github_url # ya es raw o un patron no-blob: usar tal cual def _drive_id(url: str) -> str | None: m = re.search(r"/d/([A-Za-z0-9_-]+)", url) or re.search(r"[?&]id=([A-Za-z0-9_-]+)", url) return m.group(1) if m else None # --------------------------------------------------------------------------- # # Descargas por fuente # --------------------------------------------------------------------------- # def _http_bytes(url: str, token: str | None, timeout: float) -> bytes: req = urllib.request.Request(url, headers={"User-Agent": _UA}) if token: req.add_header("Authorization", f"Bearer {token}") with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read() def _ext_from(url_or_name: str, content: bytes) -> str: low = url_or_name.lower().split("?")[0] for ext in (".json", ".png", ".webp", ".zip"): if low.endswith(ext): return ext if content[:8] == b"\x89PNG\r\n\x1a\n": return ".png" if content[:4] == b"PK\x03\x04": return ".zip" if content[:4] == b"RIFF" and content[8:12] == b"WEBP": return ".webp" return ".json" def _save(content: bytes, dest: str | None, ext: str) -> str: if dest: os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True) path = dest else: fd, path = tempfile.mkstemp(prefix="comfy_wf_", suffix=ext) os.close(fd) with open(path, "wb") as f: f.write(content) return path def _download_url(url: str, dest: str | None, token: str | None, timeout: float) -> str: content = _http_bytes(url, token, timeout) if content[:15].lstrip().startswith(b" str: file_id = _drive_id(source) if not file_id: raise _DownloadError(f"no se pudo extraer el file id de Drive de {source!r}") # Camino 1: gdown (maneja el warning de virus-scan de archivos grandes). try: import gdown # type: ignore out = dest or tempfile.mkstemp(prefix="comfy_wf_", suffix=".bin")[1] got = gdown.download(id=file_id, output=out, quiet=True) if got and os.path.exists(out) and os.path.getsize(out) > 0: return _retype_by_content(out) raise _DownloadError("gdown no devolvio archivo") except ImportError: pass # sin gdown: fallback urllib # Camino 2: descarga directa (sirve para archivos pequenos como un .json de workflow). url = f"https://drive.google.com/uc?export=download&id={file_id}" content = _http_bytes(url, None, timeout) if content[:15].lstrip().startswith(b" str: """Renombra un archivo .bin descargado a su extension real segun cabecera.""" with open(path, "rb") as f: head = f.read(16) ext = _ext_from(path, head) if path.lower().endswith(ext): return path new = os.path.splitext(path)[0] + ext os.replace(path, new) return new def _download_civitai(source: str, dest: str | None, token: str | None, timeout: float) -> str: download_url = source # Pagina de modelo civitai.com/models/ -> resolver el primer file via API v1. m = re.search(r"civitai\.com/models/(\d+)", source) if m and "/api/download/" not in source: api = f"https://civitai.com/api/v1/models/{m.group(1)}" meta = json.loads(_http_bytes(api, token, timeout)) versions = meta.get("modelVersions") or [] files = (versions[0].get("files") if versions else None) or [] if not files: raise _DownloadError(f"el modelo Civitai {m.group(1)} no expone archivos descargables") download_url = files[0].get("downloadUrl") or "" if not download_url: raise _DownloadError("Civitai no devolvio downloadUrl para el modelo") content = _http_bytes(download_url, token, timeout) if content[:15].lstrip().startswith(b" tuple[str, str]: """Extrae el primer .json/.png de un zip a un tmp y devuelve (ruta, hint).""" with zipfile.ZipFile(zip_path) as zf: names = [n for n in zf.namelist() if n.lower().endswith((".json", ".png", ".webp"))] if not names: raise _DownloadError(f"el zip {zip_path!r} no contiene .json ni .png de workflow") name = names[0] data = zf.read(name) ext = os.path.splitext(name)[1].lower() fd, out = tempfile.mkstemp(prefix="comfy_wf_zip_", suffix=ext) os.close(fd) with open(out, "wb") as f: f.write(data) return out, ext # --------------------------------------------------------------------------- # # Normalizacion a API format (reusa las funciones de import del registry) # --------------------------------------------------------------------------- # def _normalize(path: str, server: str, timeout: float) -> dict: low = path.lower() if low.endswith((".png", ".webp")): res = comfyui_import_workflow_png(path, timeout=timeout) if not res.get("ok"): return {"ok": False, "workflow": {}, "format_in": "", "error": res.get("error", "PNG sin workflow embebido")} # Preferir el chunk 'prompt' (API format). Si solo hay UI graph, normalizarlo. if res.get("prompt"): return {"ok": True, "workflow": res["prompt"], "format_in": "png-prompt", "error": ""} ui = res.get("workflow") or {} if ui: tmp = _dump_tmp_json(ui) j = comfyui_import_workflow_json(tmp, server=server, timeout=timeout) return {"ok": j.get("ok", False), "workflow": j.get("workflow", {}), "format_in": "png-workflow", "error": j.get("error", "")} return {"ok": False, "workflow": {}, "format_in": "", "error": "PNG sin chunk prompt ni workflow"} # .json / sin extension -> import_workflow_json (passthrough API o normaliza UI) res = comfyui_import_workflow_json(path, server=server, timeout=timeout) fmt = res.get("format_detected", "") return {"ok": res.get("ok", False), "workflow": res.get("workflow", {}), "format_in": fmt, "error": res.get("error", "")} def _dump_tmp_json(obj: dict) -> str: fd, tmp = tempfile.mkstemp(prefix="comfy_wf_ui_", suffix=".json") with os.fdopen(fd, "w") as f: json.dump(obj, f) return tmp def _err(source_type: str, msg: str, *, path: str = "", fmt: str = "") -> dict: return {"ok": False, "workflow": {}, "source_type": source_type, "path": path, "format_in": fmt, "error": msg} class _DownloadError(Exception): """Error de descarga interno, traducido a {ok: False, error} en la salida.""" if __name__ == "__main__": # Smoke: baja un workflow real de cubiq (Apache-2.0) desde GitHub raw. url = ( "https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/" "main/ComfyUI_Simple/SDXL_simple.json" ) out = comfyui_download_workflow(url) print(json.dumps({k: v for k, v in out.items() if k != "workflow"}, indent=2)) print("nodos:", len(out.get("workflow", {})))