chore: auto-commit (61 archivos)

- docs/capabilities/INDEX.md
- docs/capabilities/comfyui.md
- python/functions/browser/comfyui_export_workflow_ui.md
- python/functions/browser/comfyui_export_workflow_ui.py
- python/functions/browser/comfyui_load_workflow_ui.md
- python/functions/browser/comfyui_load_workflow_ui.py
- python/functions/browser/comfyui_queue_prompt_ui.md
- python/functions/browser/comfyui_queue_prompt_ui.py
- python/functions/browser/comfyui_refresh_nodes_ui.md
- python/functions/browser/comfyui_refresh_nodes_ui.py
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 00:30:30 +02:00
parent 495f545ec1
commit f12272d002
72 changed files with 6049 additions and 0 deletions
+1
View File
@@ -69,6 +69,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` | | [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay | | [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` | | [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
## Como anadir grupo ## Como anadir grupo
+193
View File
@@ -0,0 +1,193 @@
# ComfyUI — Generación de imágenes por API HTTP y por la UI (CDP)
Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
(motor de Stable Diffusion basado en grafos de nodos) de dos formas complementarias:
- **Por su API HTTP** (`/prompt`, `/history`, `/object_info`): construir un workflow en
"API format", encolarlo, esperar el resultado. Headless, scriptable, sin navegador.
- **Por su UI web vía CDP**: operar la pestaña de ComfyUI ya abierta en el navegador diario
(cargar un workflow en el grafo visual, editar widgets en vivo, encolar como si pulsaras
"Queue Prompt", exportar el grafo, refrescar combos). Lo que el usuario ve, el agente lo
toca. Todas las funciones de UI componen la primitiva de transport
[`cdp_eval_py_browser`](../../python/functions/browser/cdp_eval.md) — no reinventan CDP.
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
## Dos caminos, mismo motor
```
API HTTP (dominio ml) UI web vía CDP (dominio browser)
────────────────────── ───────────────────────────────
build_txt2img_workflow (dict API format) load_workflow_ui (dict -> grafo visual)
│ set_node_widget_ui (tuning en vivo)
▼ queue_prompt_ui (= botón Queue Prompt)
submit_workflow (POST /prompt -> id) export_workflow_ui (grafo -> dict API format)
▼ refresh_nodes_ui (recarga combos)
wait_result (poll /history -> PNG)
object_info (catálogo de nodos) download_model (dominio ml) -> baja checkpoints
```
El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` y consume
`submit_workflow`) es el puente entre ambos mundos: `load_workflow_ui` lo carga en la UI y
`export_workflow_ui` lo recupera de la UI, así que puedes mezclar libremente API y navegador.
## Funciones del grupo
### Por API HTTP — dominio `ml`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
| [comfyui_download_model_py_ml](../../python/functions/ml/comfyui_download_model.md) | `download_model(url, dest_subdir='checkpoints', *, comfyui_dir, filename, token, overwrite, timeout_s) -> dict` | Descarga un checkpoint/LoRA/VAE a `models/<dest_subdir>/`. Soporta Civitai (token) y HuggingFace. Valida que no sea HTML de error ni `.safetensors` corrupto. Impura. |
### Builders, validación e import — dominio `ml` (P0, issue 0064)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_img2img_workflow_py_ml](../../python/functions/ml/comfyui_build_img2img_workflow.md) | `build_img2img_workflow(ckpt_name, init_image, positive, negative='', *, denoise=0.6, steps, cfg, seed, ...) -> dict` | Builder img2img (Checkpoint + LoadImage → VAEEncode → KSampler con `denoise` → VAEDecode → SaveImage). **Pura**. |
| [comfyui_build_upscale_workflow_py_ml](../../python/functions/ml/comfyui_build_upscale_workflow.md) | `build_upscale_workflow(image, *, model_name='4x-UltraSharp.pth', method='model') -> dict` | Builder upscale: `method='model'` (ESRGAN: UpscaleModelLoader + ImageUpscaleWithModel) o `method='latent'` (ImageScaleBy x2 sin modelo). **Pura**. |
| [comfyui_inject_lora_py_ml](../../python/functions/ml/comfyui_inject_lora.md) | `inject_lora(workflow, lora_name, *, strength_model=1.0, strength_clip=1.0, model_node=None, clip_node=None) -> dict` | Inserta un LoraLoader en un workflow ya construido, reconectando model/clip de la fuente a sus consumidores. Encadenable. **Pura** (no muta la entrada). |
| [comfyui_validate_workflow_py_ml](../../python/functions/ml/comfyui_validate_workflow.md) | `validate_workflow(workflow, server='127.0.0.1:8188', timeout) -> dict` | Cruza class_type y nombres de modelo contra `/object_info`; devuelve `{valid, missing_nodes, missing_models}` ANTES de encolar. Compone `object_info`. Impura. |
| [comfyui_import_workflow_json_py_ml](../../python/functions/ml/comfyui_import_workflow_json.md) | `import_workflow_json(source, *, server, timeout) -> dict` | Lee un workflow JSON de URL o path local; normaliza UI graph → API format (widgets vía `object_info`); passthrough si ya es API. Impura. |
| [comfyui_import_workflow_png_py_ml](../../python/functions/ml/comfyui_import_workflow_png.md) | `import_workflow_png(png_path_or_url, *, timeout) -> dict` | Extrae el workflow embebido en los chunks `prompt` (API) / `workflow` (UI) de un PNG de ComfyUI (tEXt/zTXt/iTXt, stdlib). Path o URL. Impura. |
| [comfyui_read_png_metadata_py_ml](../../python/functions/ml/comfyui_read_png_metadata.md) | `read_png_metadata(png_path) -> dict` | Lee los parámetros de generación (modelo, seed, steps, cfg, sampler, prompts) de un PNG generado por ComfyUI. Impura (I/O disco). |
| [comfyui_fetch_output_image_py_ml](../../python/functions/ml/comfyui_fetch_output_image.md) | `fetch_output_image(filename, *, subfolder='', type_='output', server, dest_dir='.', timeout) -> dict` | Descarga el PNG generado vía GET `/view` a disco local (`wait_result` solo da metadata). Impura. |
### Potencia y assets de internet — dominio `ml` (P1, issue 0064)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_inpaint_workflow_py_ml](../../python/functions/ml/comfyui_build_inpaint_workflow.md) | `build_inpaint_workflow(ckpt_name, image, mask, positive, negative='', *, denoise=1.0, steps, cfg, seed, ...) -> dict` | Builder inpaint: CheckpointLoaderSimple + LoadImage + LoadImageMask → VAEEncodeForInpaint → KSampler → VAEDecode → SaveImage. Regenera solo la zona enmascarada. **Pura**. |
| [comfyui_build_controlnet_workflow_py_ml](../../python/functions/ml/comfyui_build_controlnet_workflow.md) | `build_controlnet_workflow(ckpt_name, control_image, cn_name, positive, negative='', *, strength=1.0, steps, cfg, seed, width, height) -> dict` | Builder ControlNet: ControlNetLoader + ControlNetApply inyectan el mapa de control sobre el condicionamiento positivo. **Pura**. |
| [comfyui_build_sdxl_refiner_workflow_py_ml](../../python/functions/ml/comfyui_build_sdxl_refiner_workflow.md) | `build_sdxl_refiner_workflow(base_ckpt, refiner_ckpt, positive, negative='', *, base_steps=20, refiner_steps=5, cfg, seed, width=1024, height=1024) -> dict` | SDXL base+refiner: dos KSamplerAdvanced encadenados (base con `return_with_leftover_noise`, refiner termina). **Pura**. |
| [comfyui_search_civitai_models_py_ml](../../python/functions/ml/comfyui_search_civitai_models.md) | `search_civitai_models(query, *, types='Checkpoint', base_model=None, sort, limit=20, token=None) -> dict` | Busca modelos/LoRAs en la API pública de Civitai → `{ok, items:[{name, type, base_model, version_id, download_url, nsfw}], count, error}`. Sin token funciona. Impura. |
| [comfyui_install_custom_node_py_ml](../../python/functions/ml/comfyui_install_custom_node.md) | `install_custom_node(repo_url, *, comfyui_dir, pip_install=True, restart=False) -> dict` | git clone en `custom_nodes/` + pip/uv install de requirements en el venv de ComfyUI. NO reinicia el server (restart=False). Impura. |
| [comfyui_resolve_workflow_deps_py_ml](../../python/functions/ml/comfyui_resolve_workflow_deps.md) | `resolve_workflow_deps(workflow, server='127.0.0.1:8188') -> dict` | Para un workflow ajeno: valida y traduce lo que falta en acciones (`{missing_nodes, missing_models, suggestions}`). Compone `validate_workflow`. Impura. |
| [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. |
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnlyCheckpointLoader
→ CLIPVisionEncode → Hunyuan3Dv2Conditioning → EmptyLatentHunyuan3Dv2 → KSampler →
VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma +
VAE 3D + encoder de imagen en un `.safetensors`). Salida **shape-only** (sin color/textura). Detalle
y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_image_to_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_image_to_3d_workflow.md) | `build_image_to_3d_workflow(image_name, ckpt_name='hunyuan3d-dit-v2-mini.safetensors', *, resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold, ...) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. **Pura**. |
| [comfyui_fetch_output_mesh_py_ml](../../python/functions/ml/comfyui_fetch_output_mesh.md) | `fetch_output_mesh(prompt_id, *, server, dest=None, timeout) -> dict` | Localiza la malla en `/history/{prompt_id}` (el SaveGLB la expone bajo la clave `"3d"`, no `"images"`) y la baja via GET `/view` a disco. Hermana de `fetch_output_image`. Impura. |
| [comfyui_install_3d_model_py_ml](../../python/functions/ml/comfyui_install_3d_model.md) | `install_3d_model(variant='mini', *, hf_token=None, comfyui_dir) -> dict` | Instala el checkpoint Hunyuan3D-2 (mini/standard/mv) en `checkpoints/`. Cascada: ya-instalado → cache de HF → descarga. Resuelve la ruta real via `extra_model_paths.yaml`. Impura. |
| [comfyui_image_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_image_to_3d_oneshot.md) | `image_to_3d_oneshot(image_path, *, server, variant='mini', dest=None, wait_timeout, **gen) -> dict` | **Pipeline** imagen en disco → malla GLB en una llamada: upload + build + submit + wait + fetch. Promoción de la secuencia (issue 0087). Impuro. |
### Por la UI web (CDP) — dominio `browser`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_load_workflow_ui_py_browser](../../python/functions/browser/comfyui_load_workflow_ui.md) | `load_workflow_ui(workflow, *, port=9222, server_url_substr='8188', filename, timeout_s) -> dict` | Carga un workflow API format en el grafo visual (`app.loadApiJson`). Impura (CDP + muta UI). |
| [comfyui_set_node_widget_ui_py_browser](../../python/functions/browser/comfyui_set_node_widget_ui.md) | `set_node_widget_ui(node, widget_name, value, *, match='type', port, server_url_substr, timeout_s) -> dict` | Edita en vivo un widget de un nodo (texto del CLIPTextEncode, steps/seed/cfg del KSampler). Localiza por type/id/title. Impura. |
| [comfyui_queue_prompt_ui_py_browser](../../python/functions/browser/comfyui_queue_prompt_ui.md) | `queue_prompt_ui(*, port, server_url_substr, timeout_s) -> dict` | Encola el grafo actual (`app.queuePrompt(0)`), = botón "Queue Prompt". Impura (dispara GPU). |
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
prompt y los pasos en vivo, encolas y esperas el PNG. Requiere el server en `127.0.0.1:8188`
y la pestaña de ComfyUI abierta en un Chrome con `--remote-debugging-port=9222`.
```python
import sys, os, time, glob
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
# 1. Construir (API format, función pura) con un prefijo de salida localizable.
prefix = f"demo_{int(time.time())}"
wf = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="placeholder",
steps=8, seed=111, filename_prefix=prefix,
)
# 2. Cargar el grafo en la UI del navegador del usuario.
comfyui_load_workflow_ui(wf) # {'ok': True, 'loaded': True}
# 3. Tuning en vivo: prompt (widget de texto) + pasos (widget numérico).
comfyui_set_node_widget_ui("CLIPTextEncode", "text",
"a green glass bottle on a marble shelf", match="type")
comfyui_set_node_widget_ui("KSampler", "steps", 12, match="type")
# 4. Encolar (= pulsar "Queue Prompt") y localizar el PNG nuevo en output/.
comfyui_queue_prompt_ui() # {'ok': True, 'queued': True}
before = set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png")))
while True:
new = [p for p in set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png"))) - before
if prefix in os.path.basename(p)]
if new:
print("PNG generado:", new[0]); break
time.sleep(1.5)
```
Variante 100% headless (sin navegador): cambia los pasos 2-4 por
`comfyui_submit_workflow(wf)``comfyui_wait_result(prompt_id)`. Misma capacidad, sin UI.
## Ejemplo canónico imagen → 3D (Hunyuan3D-2 nativo)
Una imagen de un objeto → su malla GLB, en una sola llamada. Requiere el server en
`127.0.0.1:8188` y el checkpoint mini instalado (lo hace `install_3d_model` la primera vez,
reutilizando la cache de HF; ~60 s de GPU por reconstrucción en una RTX 3070).
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_install_3d_model import comfyui_install_3d_model
from pipelines.comfyui_image_to_3d_oneshot import comfyui_image_to_3d_oneshot
# 1. Asegurar el checkpoint (instantáneo si ya está; reused_cache=True).
comfyui_install_3d_model("mini")
# 2. Imagen en disco -> malla GLB en /tmp/meshes.
res = comfyui_image_to_3d_oneshot(
os.path.expanduser("~/ComfyUI/input/3d_src_robot_00001_.png"),
dest="/tmp/meshes", variant="mini", seed=42,
)
print(res["mesh_path"], res["faces"]) # /tmp/meshes/3d_mesh_00001_.glb 1668040
```
Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_name)`
`submit_workflow``wait_result``fetch_output_mesh(prompt_id, dest=...)`.
## Fronteras
- **No es un grupo de generación genérica de imágenes**: cubre ComfyUI concretamente (su API
y su frontend litegraph). Para otros backends (Automatic1111, diffusers) harían falta otras
funciones.
- **Los builders cubren txt2img, img2img, upscale, LoRA stacks, inpaint, ControlNet y SDXL
refiner** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`,
`inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`, `build_sdxl_refiner_workflow`).
Workflows aún más complejos (multi-ControlNet avanzado, IPAdapter, vídeo) se montan en la UI a mano
y se capturan con `export_workflow_ui`, o se importan de internet con
`import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias con
`resolve_workflow_deps` (instala nodos con `install_custom_node`, descubre modelos con
`search_civitai_models`) y se validan con `validate_workflow` antes de encolar.
- **Las funciones `*_ui` requieren la pestaña abierta y el navegador con CDP** (puerto 9222 por
defecto). Sin target que matchee `server_url_substr`, devuelven `ok=False`. Para automatización
desatendida sin navegador, usa el camino API (`submit_workflow` + `wait_result`).
- **`download_model` no gestiona el catálogo del server**: tras bajar un modelo, llama
`refresh_nodes_ui` (o recarga la página) para que ComfyUI lo vea en los combos.
- **El camino imagen→3D es shape-only**: los nodos nativos de Hunyuan3D-2
(`build_image_to_3d_workflow`, `fetch_output_mesh`, `install_3d_model`, `image_to_3d_oneshot`)
reconstruyen la FORMA, sin color ni textura horneada. Para color/textura haría falta el wrapper
de kijai (compila `custom_rasterizer`) — fuera del grupo. Tampoco hay decimación: las mallas son
densas (decenas de MB de GLB). Decisión y comparación vs la app local en
`reports/0069-2026-06-23-comfyui-img-to-3d.md`.
- La primitiva de transport CDP es [`cdp_eval`](../../python/functions/browser/cdp_eval.md) (grupo
navegador): si necesitas leer/escribir algo del grafo que estas funciones no cubren, compón
`cdp_eval` directamente antes de inventar nada.
@@ -0,0 +1,66 @@
---
name: comfyui_export_workflow_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_export_workflow_ui(*, port: int = 9222, server_url_substr: str = '8188', api_format: bool = True, save_path: str | None = None, timeout_s: float = 15.0) -> dict"
description: "Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP. Con api_format=True devuelve el API format ((await app.graphToPrompt()).output, listo para POST /prompt); con False el UI graph serializado (app.graph.serialize(), recargable en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval. Impura: red (CDP) + escritura opcional."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: api_format
desc: "True devuelve el API format (POST /prompt); False el UI graph serializado (recargable con la UI). Default True."
- name: save_path
desc: "Si se pasa, ruta donde escribir el JSON (se expande ~ y se crean los padres). None no escribe a disco."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok: bool, workflow: dict, saved_to: str|None, error: str}. workflow es el API format o el UI graph segun api_format; saved_to es la ruta escrita o None."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_export_workflow_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_export_workflow_ui import comfyui_export_workflow_ui
# Captura el API format del grafo actual y guardalo a disco.
out = comfyui_export_workflow_ui(api_format=True, save_path="/tmp/wf_actual.json")
print(out["ok"], len(out["workflow"]), "nodos ->", out["saved_to"])
# El API format devuelto es re-enviable por API:
from ml.comfyui_submit_workflow import comfyui_submit_workflow
resp = comfyui_submit_workflow(out["workflow"])
```
## Cuando usarla
Para capturar lo que el usuario tiene montado en la UI y (a) re-enviarlo por API
con `comfyui_submit_workflow`, (b) persistirlo como plantilla, o (c) verificar
que un cambio hecho con `comfyui_set_node_widget_ui` quedo reflejado en el grafo.
Es el reverso de `comfyui_load_workflow_ui`.
## Gotchas
- `api_format=True` da el formato de POST /prompt (sin links visuales ni
posiciones); `api_format=False` da el grafo de UI (con todo lo necesario para
`app.loadGraphData`). Elige segun si vas a re-enviar por API o a recargar en UI.
- `graphToPrompt()` es asincrono: se espera la Promise (`await_promise=True`). Si
la pestana no tiene `window.app`, devuelve `ok=False` con error claro.
- El export refleja el estado EN VIVO del grafo, incluidos los cambios de
`comfyui_set_node_widget_ui` aplicados antes.
@@ -0,0 +1,96 @@
"""Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP.
Con api_format=True devuelve el API format (el dict que acepta POST /prompt,
extraido de `(await app.graphToPrompt()).output`); con False devuelve el UI graph
serializado (`app.graph.serialize()`, con links y posiciones para volver a
cargar en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y, si save_path, escribe en disco.
"""
import json
import os
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_export_workflow_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
api_format: bool = True,
save_path: str | None = None,
timeout_s: float = 15.0,
) -> dict:
"""Exporta el workflow actual del grafo de la UI de ComfyUI.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
api_format: True devuelve el API format (POST /prompt); False devuelve el
UI graph serializado (recargable con la UI). Default True.
save_path: si se pasa, ruta donde escribir el JSON exportado. Se expande
~ y se crean los directorios padre. None no escribe a disco.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, workflow: dict, saved_to: str|None, error: str}.
"""
if api_format:
expr = (
"(async function(){"
" if(!window.app || typeof app.graphToPrompt!=='function'){"
" return {error:'window.app.graphToPrompt no disponible en la pestana'};"
" }"
" try{ var p = await app.graphToPrompt(); return {workflow: p.output, error:''}; }"
" catch(e){ return {error:String(e)}; }"
"})()"
)
await_p = True
else:
expr = (
"(function(){"
" if(!window.app || !app.graph || typeof app.graph.serialize!=='function'){"
" return {error:'window.app.graph.serialize no disponible en la pestana'};"
" }"
" try{ return {workflow: app.graph.serialize(), error:''}; }"
" catch(e){ return {error:String(e)}; }"
"})()"
)
await_p = False
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=await_p,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "workflow": {}, "saved_to": None, "error": r["error"]}
val = r["value"] or {}
if val.get("error"):
return {"ok": False, "workflow": {}, "saved_to": None, "error": val["error"]}
workflow = val.get("workflow") or {}
saved_to = None
if save_path:
path = os.path.expanduser(save_path)
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(workflow, fh, ensure_ascii=False, indent=2)
saved_to = path
return {"ok": True, "workflow": workflow, "saved_to": saved_to, "error": ""}
if __name__ == "__main__":
out = comfyui_export_workflow_ui(api_format=True)
print(json.dumps(
{"ok": out["ok"], "nodes": len(out["workflow"]), "error": out["error"]},
ensure_ascii=False, indent=2,
))
@@ -0,0 +1,68 @@
---
name: comfyui_load_workflow_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_load_workflow_ui(workflow: dict, *, port: int = 9222, server_url_substr: str = '8188', filename: str = 'workflow.json', timeout_s: float = 20.0) -> dict"
description: "Carga un workflow ComfyUI (API format) en la UI del navegador via CDP: inyecta app.loadApiJson(<workflow>, filename) en la pestana de ComfyUI abierta y reconstruye el grafo visual. Compone cdp_eval (transport CDP). Impura: red (CDP WebSocket) + muta el grafo de la UI."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: workflow
desc: "dict en API format (claves = node_ids, valores con class_type + inputs); tipicamente el resultado de comfyui_build_txt2img_workflow."
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
- name: filename
desc: "Nombre que ComfyUI asocia al workflow cargado. Default 'workflow.json'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
output: "dict {ok: bool, loaded: bool, error: str}. ok/loaded True si app.loadApiJson termino sin excepcion en la pagina."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_load_workflow_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
wf = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a red apple on a wooden table, sharp focus",
)
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
print(comfyui_load_workflow_ui(wf)) # -> {'ok': True, 'loaded': True, 'error': ''}
```
## Cuando usarla
Cuando tienes un workflow en API format (lo construyo con
`comfyui_build_txt2img_workflow` o lo exporto de otro lado) y quieres verlo y
editarlo en la UI del navegador del usuario antes de encolarlo. Es el puente
"API format -> grafo visual": cargas, luego ajustas widgets con
`comfyui_set_node_widget_ui` y encolas con `comfyui_queue_prompt_ui`.
## Gotchas
- Requiere que la pestana de ComfyUI ya este abierta en un Chrome con
`--remote-debugging-port=9222`. Si no hay target que matchee `server_url_substr`,
`cdp_eval` devuelve error y aqui `ok=False`.
- `app.loadApiJson` REEMPLAZA el grafo actual de la UI por el del workflow; pierde
los cambios no exportados. Exporta antes con `comfyui_export_workflow_ui` si los
necesitas.
- Espera la Promise de carga (`await_promise=True`). El conteo de nodos cargados
se puede verificar con `cdp_eval("app.graph._nodes.length", target_url_substr="8188")`.
@@ -0,0 +1,81 @@
"""Carga un workflow ComfyUI (API format) en la UI del navegador via CDP.
Inyecta `app.loadApiJson(<workflow>, filename)` en la pestana de ComfyUI ya
abierta en el navegador diario, reconstruyendo el grafo visual a partir del API
format (el mismo dict que produce comfyui_build_txt2img_workflow). Compone la
primitiva de transport cdp_eval; no abre ventana nueva ni reinventa CDP.
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_load_workflow_ui(
workflow: dict,
*,
port: int = 9222,
server_url_substr: str = "8188",
filename: str = "workflow.json",
timeout_s: float = 20.0,
) -> dict:
"""Carga un workflow API format en el grafo de la UI de ComfyUI.
Args:
workflow: dict en API format (claves = node_ids, valores con class_type +
inputs). Tipicamente el resultado de comfyui_build_txt2img_workflow.
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI (default
"8188", el puerto del server). Identifica la pestana entre todas las
abiertas.
filename: nombre que ComfyUI asocia al workflow cargado.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, loaded: bool, error: str}. ok/loaded True si
app.loadApiJson termino sin excepcion en la pagina.
"""
expr = (
"(async function(){"
" if(!window.app || typeof app.loadApiJson!=='function'){"
" return {loaded:false, error:'window.app.loadApiJson no disponible en la pestana'};"
" }"
" try{"
f" await app.loadApiJson({json.dumps(workflow)}, {json.dumps(filename)});"
" return {loaded:true, error:'', nodes: app.graph? app.graph._nodes.length : -1};"
" }catch(e){ return {loaded:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "loaded": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("loaded")),
"loaded": bool(val.get("loaded")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
)
print(json.dumps(comfyui_load_workflow_ui(wf), ensure_ascii=False, indent=2))
@@ -0,0 +1,62 @@
---
name: comfyui_queue_prompt_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_queue_prompt_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 20.0) -> dict"
description: "Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar 'Queue Prompt'): llama app.queuePrompt(0) en la pestana, que serializa el grafo al API format y hace POST /prompt al server. Compone cdp_eval. Impura: red (CDP) + dispara trabajo de GPU."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
output: "dict {ok: bool, queued: bool, error: str}. queued True si app.queuePrompt resolvio sin excepcion."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_queue_prompt_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
from ml.comfyui_wait_result import comfyui_wait_result
print(comfyui_queue_prompt_ui()) # -> {'ok': True, 'queued': True, 'error': ''}
# El PNG aparece en ~/ComfyUI/output/. Para esperar el resultado por API se usa
# el prompt_id; si solo encolas desde la UI, sondea la carpeta output/ o usa el
# historial (GET /history) para localizar el archivo nuevo.
```
## Cuando usarla
Como ultimo paso del flujo por UI: tras cargar (`comfyui_load_workflow_ui`) y
ajustar widgets (`comfyui_set_node_widget_ui`), dispara la generacion sin que el
usuario pulse el boton. Reproduce exactamente "Queue Prompt" del frontend.
## Gotchas
- Tiene efecto secundario real: arranca trabajo de GPU en el server. No es
idempotente — cada llamada encola un prompt nuevo.
- `app.queuePrompt(0)` encola el grafo TAL CUAL esta en la UI en ese momento, no
un workflow que le pases. Para encolar uno concreto, cargalo antes con
`comfyui_load_workflow_ui`.
- No devuelve el `prompt_id` (la UI lo gestiona internamente). Para correlar el
resultado por API mejor usa `comfyui_submit_workflow` (devuelve prompt_id) +
`comfyui_wait_result`; esta funcion es para el caso "como si pulsara el boton".
- Si el grafo tiene errores de validacion, ComfyUI los muestra en la UI y la
Promise puede rechazar: aqui se refleja como `ok=False` con el error.
@@ -0,0 +1,61 @@
"""Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar "Queue Prompt").
Llama `app.queuePrompt(0)` en la pestana de ComfyUI abierta en el navegador, que
serializa el grafo visual al API format y hace POST /prompt al server. Compone
cdp_eval.
Funcion impura: hace red (CDP WebSocket) y dispara trabajo de GPU en el server.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_queue_prompt_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 20.0,
) -> dict:
"""Encola el grafo actual de la UI de ComfyUI.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, queued: bool, error: str}. queued True si
app.queuePrompt resolvio sin excepcion.
"""
expr = (
"(async function(){"
" if(!window.app || typeof app.queuePrompt!=='function'){"
" return {queued:false, error:'window.app.queuePrompt no disponible en la pestana'};"
" }"
" try{ await app.queuePrompt(0); return {queued:true, error:''}; }"
" catch(e){ return {queued:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "queued": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("queued")),
"queued": bool(val.get("queued")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
print(json.dumps(comfyui_queue_prompt_ui(), ensure_ascii=False, indent=2))
@@ -0,0 +1,60 @@
---
name: comfyui_refresh_nodes_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_refresh_nodes_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
description: "Refresca los combos del grafo de ComfyUI desde la UI via CDP: llama app.refreshComboInNodes(), que vuelve a pedir GET /object_info y actualiza los combos de todos los nodos (checkpoints, loras, vae, samplers) sin recargar la pagina. Util tras descargar modelos nuevos. Compone cdp_eval. Impura: red (CDP) + refresca estado de la UI."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok: bool, refreshed: bool, error: str}. refreshed True si app.refreshComboInNodes resolvio sin excepcion."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_refresh_nodes_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_download_model import comfyui_download_model
from browser.comfyui_refresh_nodes_ui import comfyui_refresh_nodes_ui
# Tras bajar un checkpoint nuevo, refresca los combos para que aparezca en los
# CheckpointLoaderSimple sin recargar la pagina.
comfyui_download_model("https://.../nuevo.safetensors", "checkpoints")
print(comfyui_refresh_nodes_ui()) # -> {'ok': True, 'refreshed': True, 'error': ''}
```
## Cuando usarla
Justo despues de añadir modelos a `~/ComfyUI/models/` (con
`comfyui_download_model` o a mano) para que los nodos de la UI vean los archivos
nuevos en sus combos sin un F5 que perderia el grafo no guardado.
## Gotchas
- Solo refresca combos (listas que vienen de /object_info): checkpoints, loras,
vae, samplers, schedulers. NO recarga el grafo ni cambia los valores ya
seleccionados.
- Si el server no ve aun el archivo nuevo (lo copiaste a la carpeta equivocada o
ComfyUI no reescanea), el combo seguira sin mostrarlo aunque `refreshed=True`:
el refresh fue exitoso pero el catalogo del server no lo incluye.
- Requiere la pestana de ComfyUI abierta en el Chrome con CDP; sin target,
`ok=False`.
@@ -0,0 +1,63 @@
"""Refresca los combos del grafo de ComfyUI desde la UI via CDP.
Llama `app.refreshComboInNodes()`, que vuelve a pedir GET /object_info al server
y actualiza los combos de todos los nodos (lista de checkpoints, loras, vaes,
samplers) sin recargar la pagina. Util tras descargar modelos nuevos con
comfyui_download_model para que aparezcan en los CheckpointLoaderSimple sin un
F5. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y refresca estado de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_refresh_nodes_ui(
*,
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 15.0,
) -> dict:
"""Refresca los combos (checkpoints/loras/vae) de los nodos del grafo.
Args:
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok: bool, refreshed: bool, error: str}. refreshed True si
app.refreshComboInNodes resolvio sin excepcion.
"""
expr = (
"(async function(){"
" if(!window.app || typeof app.refreshComboInNodes!=='function'){"
" return {refreshed:false, error:'window.app.refreshComboInNodes no disponible en la pestana'};"
" }"
" try{ await app.refreshComboInNodes(); return {refreshed:true, error:''}; }"
" catch(e){ return {refreshed:false, error:String(e)}; }"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=True,
timeout_s=timeout_s,
)
if not r["ok"]:
return {"ok": False, "refreshed": False, "error": r["error"]}
val = r["value"] or {}
return {
"ok": bool(val.get("refreshed")),
"refreshed": bool(val.get("refreshed")),
"error": val.get("error", ""),
}
if __name__ == "__main__":
print(json.dumps(comfyui_refresh_nodes_ui(), ensure_ascii=False, indent=2))
@@ -0,0 +1,72 @@
---
name: comfyui_set_node_widget_ui
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def comfyui_set_node_widget_ui(node: str, widget_name: str, value, *, match: str = 'type', port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
description: "Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP. Localiza el nodo en app.graph._nodes por type (comfyClass), id o title; asigna widget.value, invoca widget.callback si existe y marca el canvas dirty. Cubre widgets numericos (steps/cfg/seed) y de texto (CLIPTextEncode.text). Compone cdp_eval. Impura: red (CDP) + muta el grafo."
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
uses_functions: ["cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json"]
params:
- name: node
desc: "Identificador del nodo a localizar, interpretado segun `match`."
- name: widget_name
desc: "Nombre del widget a editar (ej. 'text', 'steps', 'seed', 'cfg', 'sampler_name')."
- name: value
desc: "Nuevo valor (str, int, float o bool). Se serializa a JSON para inyectarlo."
- name: match
desc: "Criterio de busqueda: 'type' (por comfyClass/type, ej. 'CLIPTextEncode'/'KSampler'), 'id' (por n.id) o 'title' (por titulo visible). Default 'type'."
- name: port
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
- name: server_url_substr
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
- name: timeout_s
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
output: "dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}. Con match='type' y varios matches, actua sobre el primero y reporta cuantos coincidieron."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/comfyui_set_node_widget_ui.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
# Cambiar el prompt positivo (widget de texto del CLIPTextEncode) ...
print(comfyui_set_node_widget_ui(
"CLIPTextEncode", "text", "a blue ceramic mug, studio light", match="type"))
# ... y los pasos del sampler (widget numerico).
print(comfyui_set_node_widget_ui("KSampler", "steps", 25, match="type"))
# -> {'ok': True, 'matched_nodes': 2, 'set': True, 'old_value': 20, 'new_value': 25, 'error': ''}
```
## Cuando usarla
Para ajustar parametros de un workflow ya cargado en la UI sin reconstruirlo:
cambiar el prompt, los steps, la seed, el cfg o el sampler en vivo antes de
encolar con `comfyui_queue_prompt_ui`. Es el paso de "tuning" entre
`comfyui_load_workflow_ui` y la cola.
## Gotchas
- Con `match="type"` y un workflow txt2img hay DOS `CLIPTextEncode` (positivo y
negativo): `matched_nodes=2` y solo se edita el primero (el positivo en el grafo
por defecto). Para apuntar al negativo usa `match="id"` o `match="title"`.
- Nodo o widget inexistente NO lanza: devuelve `ok=False`, `set=False` y un
`error` claro ("sin nodo que matchee ..." / "el nodo no tiene widget ...").
- `widget.callback` se invoca con el nuevo valor para propagar el cambio (combos,
derivados); si el callback de un widget concreto espera mas argumentos, el fallo
se traga (try/catch) y el `value` ya queda asignado igualmente.
- El cambio vive en el grafo de la UI; para persistirlo a un archivo exportalo con
`comfyui_export_workflow_ui` o encolalo.
@@ -0,0 +1,103 @@
"""Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP.
Localiza un nodo en `app.graph._nodes` por su tipo (comfyClass), su id o su
titulo, y asigna el valor del widget cuyo `name` coincide. Cubre tanto widgets
numericos (steps, cfg, seed del KSampler) como de texto (el `text` de un
CLIPTextEncode). Tras asignar `widget.value` invoca `widget.callback` si existe
para propagar el cambio y marca el canvas dirty. Compone cdp_eval.
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
"""
import json
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
from cdp_eval import cdp_eval
except ImportError: # importado como paquete (sys.path = python/functions)
from browser.cdp_eval import cdp_eval
def comfyui_set_node_widget_ui(
node: str,
widget_name: str,
value,
*,
match: str = "type",
port: int = 9222,
server_url_substr: str = "8188",
timeout_s: float = 15.0,
) -> dict:
"""Asigna el valor de un widget de un nodo del grafo en vivo.
Args:
node: identificador del nodo a localizar, interpretado segun `match`.
widget_name: nombre del widget a editar (ej. "text", "steps", "seed",
"cfg", "sampler_name").
value: nuevo valor (str, int, float o bool). Se serializa a JSON.
match: criterio de busqueda del nodo. "type" (por comfyClass/type, ej.
"CLIPTextEncode" o "KSampler"), "id" (por n.id) o "title" (por el
titulo visible del nodo). Default "type".
port: puerto de remote debugging del Chrome diario. Default 9222.
server_url_substr: substring de la URL de la pestana de ComfyUI.
timeout_s: timeout de la conexion CDP en segundos.
Returns:
dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}.
Si `match="type"` produce varios nodos, actua sobre el primero y reporta
cuantos coincidieron en matched_nodes.
"""
expr = (
"(function(){"
" if(!window.app || !app.graph) return {matched_nodes:0, set:false, error:'window.app.graph no disponible'};"
" var nodes = app.graph._nodes || [];"
f" var key = {json.dumps(match)};"
f" var target = {json.dumps(node)};"
f" var wname = {json.dumps(widget_name)};"
f" var nval = {json.dumps(value)};"
" var matches = nodes.filter(function(n){"
" if(key==='id') return String(n.id)===String(target);"
" if(key==='title') return n.title===target;"
" return (n.comfyClass||n.type)===target;"
" });"
" if(matches.length===0) return {matched_nodes:0, set:false, error:'sin nodo que matchee '+key+'='+target};"
" var n = matches[0];"
" var w = (n.widgets||[]).find(function(x){return x.name===wname;});"
" if(!w) return {matched_nodes:matches.length, set:false, error:'el nodo no tiene widget \"'+wname+'\"'};"
" var old = w.value;"
" w.value = nval;"
" if(typeof w.callback==='function'){ try{ w.callback(nval); }catch(e){} }"
" if(typeof app.graph.setDirtyCanvas==='function') app.graph.setDirtyCanvas(true,true);"
" return {matched_nodes:matches.length, set:true, old_value:old, new_value:w.value, error:''};"
"})()"
)
r = cdp_eval(
expr,
port=port,
target_url_substr=server_url_substr,
await_promise=False,
timeout_s=timeout_s,
)
if not r["ok"]:
return {
"ok": False,
"matched_nodes": 0,
"set": False,
"old_value": None,
"new_value": None,
"error": r["error"],
}
val = r["value"] or {}
return {
"ok": bool(val.get("set")),
"matched_nodes": val.get("matched_nodes", 0),
"set": bool(val.get("set")),
"old_value": val.get("old_value"),
"new_value": val.get("new_value"),
"error": val.get("error", ""),
}
if __name__ == "__main__":
out = comfyui_set_node_widget_ui(
"KSampler", "steps", 25, match="type"
)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,89 @@
---
name: comfyui_build_controlnet_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_controlnet_workflow(ckpt_name: str, control_image: str, cn_name: str, positive: str, negative: str = \"\", *, strength: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, width: int = 512, height: int = 512) -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img guiado por ControlNet en API format: CheckpointLoaderSimple + EmptyLatentImage + LoadImage (mapa de control) + ControlNetLoader -> ControlNetApply (inyecta el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, controlnet, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: control_image
desc: "Nombre del archivo de la imagen de control dentro de input/ del servidor (mapa canny/depth/openpose preprocesado); lo carga el nodo LoadImage."
- name: cn_name
desc: "Nombre del modelo ControlNet en models/controlnet/ tal como lo lista comfyui_object_info para ControlNetLoader (control_net_name)."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: strength
desc: "Fuerza con la que el ControlNet condiciona la generacion (0.0 = nula, 1.0 = plena). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px (multiplo de 8). keyword-only."
- name: height
desc: "Alto del latente/imagen en px (multiplo de 8). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', EmptyLatentImage '5', LoadImage '10', ControlNetLoader '12', CLIPTextEncode '6'/'7', ControlNetApply '13', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_controlnet_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_controlnet_workflow import comfyui_build_controlnet_workflow
wf = comfyui_build_controlnet_workflow(
ckpt_name="dreamshaper_8.safetensors",
control_image="pose_canny.png", # mapa de control en input/
cn_name="control_v11p_sd15_canny.pth", # modelo en models/controlnet/
positive="a knight in shining armor, dramatic lighting",
negative="blurry, low quality",
strength=0.8,
seed=42,
)
# wf["13"]["class_type"] == "ControlNetApply"
# wf["13"]["inputs"]["conditioning"] == ["6", 0] # aplica sobre el positivo
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler usa el cond condicionado
```
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
`*` keyword-only); usa el import o un heredoc.
## Cuando usarla
Cuando quieras controlar la composicion de la imagen con una guia estructural
(bordes canny, profundidad depth, pose openpose, scribble) en lugar de dejar la
composicion al azar del prompt. Necesitas el mapa de control ya preprocesado en
`input/` y el modelo ControlNet adecuado descargado en `models/controlnet/`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
- `control_image` debe ser el mapa de control YA preprocesado (ej. salida de un
preprocesador canny/depth). Este builder NO incluye el nodo preprocesador; si
pasas una foto normal, el ControlNet la usara tal cual.
- Usa el nodo clasico `ControlNetApply` (un solo `strength`). Para ControlNet
avanzado con `start_percent`/`end_percent` necesitas `ControlNetApplyAdvanced`
(no cubierto aqui): montalo en la UI y captura con `comfyui_export_workflow_ui`.
- `cn_name` debe corresponder a la version del checkpoint (un ControlNet de SD1.5
no sirve con un checkpoint SDXL). Valida antes con `comfyui_validate_workflow`.
- Es pura: NO valida que los modelos existan en el servidor. Valida antes.
@@ -0,0 +1,129 @@
"""Construye un workflow ComfyUI con ControlNet en API format (nodos numerados).
ControlNet condiciona la generacion con una imagen de control (canny, depth,
pose, scribble, ...). Cadena de nodos: CheckpointLoaderSimple + EmptyLatentImage
+ LoadImage (imagen de control) + ControlNetLoader -> ControlNetApply (inyecta
el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode ->
SaveImage. Los CLIPTextEncode codifican el prompt positivo y el negativo.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_controlnet_workflow(
ckpt_name: str,
control_image: str,
cn_name: str,
positive: str,
negative: str = "",
*,
strength: float = 1.0,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
width: int = 512,
height: int = 512,
) -> dict:
"""Construye el dict de un workflow txt2img guiado por ControlNet.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
control_image: nombre del archivo de la imagen de control dentro de la
carpeta input/ del servidor ComfyUI (lo carga el nodo LoadImage).
Suele ser un mapa preprocesado (canny/depth/openpose).
cn_name: nombre del modelo ControlNet en models/controlnet/ tal como lo
lista comfyui_object_info para ControlNetLoader (control_net_name).
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
strength: fuerza con la que el ControlNet condiciona la generacion
(0.0 = nula, 1.0 = plena). keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
keyword-only.
width: ancho del latente/imagen en px (multiplo de 8). keyword-only.
height: alto del latente/imagen en px (multiplo de 8). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": control_image},
},
"12": {
"class_type": "ControlNetLoader",
"inputs": {"control_net_name": cn_name},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"13": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["6", 0],
"control_net": ["12", 0],
"image": ["10", 0],
"strength": strength,
},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["13", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_controlnet", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_controlnet_workflow(
ckpt_name="dreamshaper_8.safetensors",
control_image="pose_canny.png",
cn_name="control_v11p_sd15_canny.pth",
positive="a knight in shining armor, dramatic lighting",
negative="blurry, low quality",
strength=0.8,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,97 @@
---
name: comfyui_build_image_to_3d_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_image_to_3d_workflow(image_name: str, ckpt_name: str = \"hunyuan3d-dit-v2-mini.safetensors\", *, resolution: int = 3072, steps: int = 30, cfg: float = 5.5, seed: int = 0, octree_resolution: int = 256, num_chunks: int = 8000, threshold: float = 0.6, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"3d_mesh\") -> dict"
description: "Construye el dict de un workflow ComfyUI imagen->malla 3D en API format usando los nodos NATIVOS de Hunyuan3D-2 de ComfyUI 0.26.0 (sin custom node). Cadena de 9 nodos: LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode -> Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler -> VAEDecodeHunyuan3D -> VoxelToMeshBasic -> SaveGLB. El SaveGLB produce un .glb. Pura, sin red ni I/O."
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image_name
desc: "Nombre del archivo de imagen en el input/ del servidor ComfyUI (ej. '3d_src_robot_00001_.png'). Lo carga el nodo LoadImage; debe existir ya en input/ (subelo antes o usa el pipeline oneshot)."
- name: ckpt_name
desc: "Nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor (ej. 'hunyuan3d-dit-v2-mini.safetensors'). Debe estar en la lista de comfyui_object_info para ImageOnlyCheckpointLoader."
- name: resolution
desc: "Resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor = mas detalle de forma y mas VRAM. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler de difusion 3D. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale del KSampler. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la malla. keyword-only."
- name: octree_resolution
desc: "Resolucion del grid de voxels en VAEDecodeHunyuan3D. Mayor = malla mas densa (mas caras) y mas memoria. keyword-only."
- name: num_chunks
desc: "Numero de chunks de decode del VAE 3D; controla el troceado del grid para caber en memoria. keyword-only."
- name: threshold
desc: "Umbral de iso-superficie de VoxelToMeshBasic (marching cubes simple sobre el grid de voxels). keyword-only."
- name: sampler_name
desc: "Nombre del sampler del KSampler (ej. 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal'). keyword-only."
- name: filename_prefix
desc: "Prefijo del archivo de malla que SaveGLB escribe en output/ (ej. '3d_mesh' -> '3d_mesh_00001_.glb'). keyword-only."
output: "dict en API format con node_ids '1'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow. El nodo '9' (SaveGLB) produce el archivo .glb en el output del servidor."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_image_to_3d_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_image_to_3d_workflow import comfyui_build_image_to_3d_workflow
wf = comfyui_build_image_to_3d_workflow(
image_name="3d_src_robot_00001_.png",
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
seed=42,
)
# wf["2"]["class_type"] == "ImageOnlyCheckpointLoader"
# wf["3"]["inputs"]["clip_vision"] == ["2", 1] # CLIP_VISION del loader
# wf["7"]["class_type"] == "VAEDecodeHunyuan3D"
# wf["9"]["class_type"] == "SaveGLB"
```
O lanzable directo con: `./fn run comfyui_build_image_to_3d_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Antes de enviar una reconstruccion imagen->3D a ComfyUI: construye aqui el dict
del workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que tengas una
imagen ya en el `input/` del servidor y quieras una malla GLB sin escribir el
grafo de 9 nodos a mano. Para hacerlo end-to-end desde una imagen en disco (subir
+ build + submit + wait + fetch en una llamada), usa el pipeline
`comfyui_image_to_3d_oneshot`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se pega en la UI tal cual; es el formato que acepta POST /prompt.
- Usa nodos NATIVOS de Hunyuan3D-2 de ComfyUI >= 0.26.0. En versiones anteriores
(sin `ImageOnlyCheckpointLoader`/`VAEDecodeHunyuan3D`/`SaveGLB`) el server
rechaza el workflow al enviarlo. Esta funcion es pura y no valida contra el
server: valida con `comfyui_validate_workflow` antes de encolar si dudas.
- `image_name` debe existir en el `input/` del servidor ANTES de enviar. Esta
funcion solo referencia el nombre; no sube nada (es pura). El pipeline oneshot
hace el upload.
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
servidor (instalalo con `comfyui_install_3d_model`).
- El camino nativo es **shape-only**: la malla sale SIN color/textura. Para color
por vertice o textura horneada haria falta el wrapper de kijai (compila
custom_rasterizer) — fuera de alcance.
- `VoxelToMeshBasic` no garantiza malla estanca (`watertight=False` es esperable).
Para watertight probar el nodo `VoxelToMesh` con su algoritmo alternativo.
- `octree_resolution` alto (256) produce mallas muy densas (decenas de MB de GLB,
>1M caras) sin decimacion. Para web conviene un paso de simplificacion posterior.
@@ -0,0 +1,136 @@
"""Construye un workflow ComfyUI imagen -> malla 3D en "API format" (Hunyuan3D-2 nativo).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
El workflow usa los nodos NATIVOS de Hunyuan3D-2 que trae ComfyUI 0.26.0 (sin
custom node de terceros): una imagen de entrada se reconstruye en una malla 3D
GLB. Cadena de 9 nodos:
LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode ->
Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler ->
VAEDecodeHunyuan3D -> VoxelToMeshBasic -> SaveGLB
El checkpoint Hunyuan3D-2 (mini/standard) es self-contained: ImageOnlyCheckpointLoader
devuelve MODEL, CLIP_VISION y VAE de un solo .safetensors.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_image_to_3d_workflow(
image_name: str,
ckpt_name: str = "hunyuan3d-dit-v2-mini.safetensors",
*,
resolution: int = 3072,
steps: int = 30,
cfg: float = 5.5,
seed: int = 0,
octree_resolution: int = 256,
num_chunks: int = 8000,
threshold: float = 0.6,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "3d_mesh",
) -> dict:
"""Construye el dict del workflow imagen->3D nativo (Hunyuan3D-2).
Args:
image_name: nombre del archivo de imagen en el `input/` del servidor
ComfyUI (ej. "3d_src_robot_00001_.png"). Lo carga el nodo LoadImage;
debe existir ya en input/ (subelo antes, o usa el pipeline oneshot).
ckpt_name: nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor
(ej. "hunyuan3d-dit-v2-mini.safetensors"). Debe estar entre los que
devuelve comfyui_object_info para ImageOnlyCheckpointLoader.
resolution: resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor =
mas detalle de forma y mas VRAM. keyword-only.
steps: pasos de sampling del KSampler de difusion 3D. keyword-only.
cfg: classifier-free guidance scale del KSampler. keyword-only.
seed: semilla del KSampler (0 = determinista; cambia para variar la
malla). keyword-only.
octree_resolution: resolucion del grid de voxels en VAEDecodeHunyuan3D.
Mayor = malla mas densa (mas caras) y mas memoria. keyword-only.
num_chunks: numero de chunks de decode del VAE 3D; controla el troceado
del grid para caber en memoria. keyword-only.
threshold: umbral de iso-superficie de VoxelToMeshBasic (marching cubes
simple sobre el grid de voxels). keyword-only.
sampler_name: nombre del sampler del KSampler (ej. "euler"). keyword-only.
scheduler: scheduler del sampler (ej. "normal"). keyword-only.
filename_prefix: prefijo del archivo de malla que SaveGLB escribe en
output/ (ej. "3d_mesh" -> "3d_mesh_00001_.glb"). keyword-only.
Returns:
dict en API format con node_ids "1".."9" como claves; cada valor tiene
class_type + inputs. Listo para comfyui_submit_workflow. El nodo "9"
(SaveGLB) produce el archivo .glb en el output del servidor.
"""
return {
"1": {
"class_type": "LoadImage",
"inputs": {"image": image_name},
},
"2": {
"class_type": "ImageOnlyCheckpointLoader",
"inputs": {"ckpt_name": ckpt_name},
},
"3": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": ["2", 1],
"image": ["1", 0],
"crop": "center",
},
},
"4": {
"class_type": "Hunyuan3Dv2Conditioning",
"inputs": {"clip_vision_output": ["3", 0]},
},
"5": {
"class_type": "EmptyLatentHunyuan3Dv2",
"inputs": {"resolution": resolution, "batch_size": 1},
},
"6": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["2", 0],
"positive": ["4", 0],
"negative": ["4", 1],
"latent_image": ["5", 0],
},
},
"7": {
"class_type": "VAEDecodeHunyuan3D",
"inputs": {
"samples": ["6", 0],
"vae": ["2", 2],
"num_chunks": num_chunks,
"octree_resolution": octree_resolution,
},
},
"8": {
"class_type": "VoxelToMeshBasic",
"inputs": {"voxel": ["7", 0], "threshold": threshold},
},
"9": {
"class_type": "SaveGLB",
"inputs": {"mesh": ["8", 0], "filename_prefix": filename_prefix},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_image_to_3d_workflow(
image_name="3d_src_robot_00001_.png",
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_img2img_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_img2img_workflow(ckpt_name: str, init_image: str, positive: str, negative: str = \"\", *, denoise: float = 0.6, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
description: "Construye el dict de un workflow ComfyUI img2img en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage -> VAEEncode -> KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, img2img, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: init_image
desc: "Nombre del archivo de imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: denoise
desc: "Fuerza de denoising del KSampler (0.0 = identica a la base, 1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_img2img_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_img2img_workflow import comfyui_build_img2img_workflow
wf = comfyui_build_img2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
init_image="cabin.png", # archivo en el input/ de ComfyUI
positive="a cozy cabin in the woods, golden hour",
negative="blurry, low quality",
denoise=0.55, # conserva ~la mitad de la imagen base
seed=42,
)
# wf["11"]["class_type"] == "VAEEncode"
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente de la imagen
# wf["3"]["inputs"]["denoise"] == 0.55
```
El bloque de arriba se lanza con el python del venv (`python/.venv/bin/python3`). Nota: `./fn run` directo no aplica a este builder porque su firma usa `*` (keyword-only) y el generador de runner de `fn run` no lo soporta — igual que en `comfyui_build_txt2img_workflow`. Usa el import de arriba o un heredoc.
## Cuando usarla
Cuando quieras transformar una imagen existente con un prompt (variaciones,
restyling, refine) en lugar de generar desde ruido. Sube primero la imagen base
al `input/` del servidor (o cargala por la UI) y pasa su nombre en `init_image`.
Para generar desde cero usa `comfyui_build_txt2img_workflow`; para ampliar una
imagen usa `comfyui_build_upscale_workflow`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- `init_image` debe existir en la carpeta `input/` del servidor (no es un path
local arbitrario). Subela antes con la UI o copiala a `~/ComfyUI/input/`.
- `denoise` controla cuanto se conserva de la base: cerca de 1.0 ignora la
imagen (casi txt2img); cerca de 0.0 apenas la cambia. 0.4-0.7 es el rango util.
- Asume que el checkpoint trae VAE embebido (VAEEncode/VAEDecode usan `["4", 2]`).
Para un VAE externo cambia esas conexiones.
- Es pura: NO valida que `ckpt_name`/`init_image` existan en el servidor. Si no
existen, ComfyUI rechaza el workflow con HTTP 400 al enviarlo. Valida antes con
`comfyui_validate_workflow`.
@@ -0,0 +1,108 @@
"""Construye un workflow ComfyUI img2img en API format (dict de nodos numerados).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_img2img_workflow(
ckpt_name: str,
init_image: str,
positive: str,
negative: str = "",
*,
denoise: float = 0.6,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
) -> dict:
"""Construye el dict de un workflow img2img para SD1.5 / SDXL.
Cadena de nodos: CheckpointLoaderSimple + LoadImage -> VAEEncode ->
KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode ->
SaveImage. CLIPTextEncode codifica el prompt positivo y el negativo.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
init_image: nombre del archivo de imagen base dentro de la carpeta
input/ del servidor ComfyUI (lo que carga el nodo LoadImage).
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
denoise: fuerza de denoising del KSampler (0.0 = identica a la base,
1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. keyword-only.
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": init_image},
},
"11": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["10", 0], "vae": ["4", 2]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["11", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_img2img", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_img2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
init_image="example.png",
positive="a cozy cabin in the woods, golden hour, sharp focus",
negative="blurry, low quality",
denoise=0.6,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,94 @@
---
name: comfyui_build_inpaint_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_inpaint_workflow(ckpt_name: str, image: str, mask: str, positive: str, negative: str = \"\", *, denoise: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
description: "Construye el dict de un workflow ComfyUI inpaint en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage (base) + LoadImageMask (mascara) -> VAEEncodeForInpaint -> KSampler -> VAEDecode -> SaveImage. Regenera solo la zona enmascarada conservando el resto. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, inpaint, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
- name: image
desc: "Nombre del archivo de la imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: mask
desc: "Nombre del archivo de la mascara dentro de input/ del servidor; lo carga LoadImageMask. Las zonas blancas se regeneran."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la zona enmascarada."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: denoise
desc: "Fuerza de denoising del KSampler (1.0 regenera por completo la zona enmascarada; <1.0 conserva parte de la base). keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_inpaint_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_inpaint_workflow import comfyui_build_inpaint_workflow
wf = comfyui_build_inpaint_workflow(
ckpt_name="dreamshaper_8.safetensors",
image="room.png", # imagen base en el input/ de ComfyUI
mask="room_mask.png", # mascara: blanco = zona a regenerar
positive="a vase of red flowers on the table, sharp focus",
negative="blurry, low quality",
denoise=1.0,
seed=42,
)
# wf["11"]["class_type"] == "VAEEncodeForInpaint"
# wf["11"]["inputs"]["mask"] == ["12", 0] # mascara desde LoadImageMask
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente inpaint
```
El bloque se lanza con el python del venv (`python/.venv/bin/python3`). `./fn run`
directo no aplica a este builder porque su firma usa `*` (keyword-only); usa el
import de arriba o un heredoc.
## Cuando usarla
Cuando quieras reemplazar solo una parte de una imagen (quitar un objeto, cambiar
un detalle, rellenar una zona) conservando el resto intacto. Sube la imagen base
y la mascara al `input/` del servidor y pasa sus nombres. Para transformar la
imagen entera usa `comfyui_build_img2img_workflow`; para generar desde cero
`comfyui_build_txt2img_workflow`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- `image` y `mask` deben existir en la carpeta `input/` del servidor (no son
paths locales arbitrarios). Subelos antes con la UI o copialos a `~/ComfyUI/input/`.
- `LoadImageMask` lee el canal `red` por defecto: la mascara debe tener la zona a
regenerar en blanco. Si tu mascara usa el canal alpha, cambia `channel` en el
nodo '12' tras construir.
- `VAEEncodeForInpaint` usa `grow_mask_by: 6` (suaviza el borde de la mascara).
Ajustalo en el nodo '11' si necesitas un borde mas duro o mas difuso.
- Asume que el checkpoint trae VAE embebido (VAEEncodeForInpaint/VAEDecode usan
`["4", 2]`). Para un VAE externo cambia esas conexiones.
- Es pura: NO valida que `ckpt_name`/`image`/`mask` existan en el servidor.
Valida antes con `comfyui_validate_workflow`.
@@ -0,0 +1,123 @@
"""Construye un workflow ComfyUI inpaint en API format (dict de nodos numerados).
Inpaint: se reemplaza la zona enmascarada de una imagen conservando el resto.
Cadena de nodos: CheckpointLoaderSimple + LoadImage (imagen base) +
LoadImageMask (mascara) -> VAEEncodeForInpaint (codifica el latente respetando
la mascara) -> KSampler -> VAEDecode -> SaveImage. Los CLIPTextEncode codifican
el prompt positivo y el negativo.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_inpaint_workflow(
ckpt_name: str,
image: str,
mask: str,
positive: str,
negative: str = "",
*,
denoise: float = 1.0,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
) -> dict:
"""Construye el dict de un workflow inpaint para SD1.5 / SDXL.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
comfyui_object_info para CheckpointLoaderSimple.
image: nombre del archivo de la imagen base dentro de la carpeta input/
del servidor ComfyUI (lo carga el nodo LoadImage).
mask: nombre del archivo de la mascara dentro de input/ del servidor
(lo carga LoadImageMask; las zonas blancas se regeneran).
positive: prompt positivo (lo que se quiere ver en la zona enmascarada).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
denoise: fuerza de denoising del KSampler (1.0 regenera por completo la
zona enmascarada; <1.0 conserva parte de la base). keyword-only.
steps: pasos de sampling del KSampler. keyword-only.
cfg: classifier-free guidance scale. keyword-only.
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
keyword-only.
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"10": {
"class_type": "LoadImage",
"inputs": {"image": image},
},
"12": {
"class_type": "LoadImageMask",
"inputs": {"image": mask, "channel": "red"},
},
"11": {
"class_type": "VAEEncodeForInpaint",
"inputs": {
"pixels": ["10", 0],
"vae": ["4", 2],
"mask": ["12", 0],
"grow_mask_by": 6,
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["11", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_inpaint", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_inpaint_workflow(
ckpt_name="dreamshaper_8.safetensors",
image="room.png",
mask="room_mask.png",
positive="a vase of red flowers on the table, sharp focus",
negative="blurry, low quality",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_sdxl_refiner_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_sdxl_refiner_workflow(base_ckpt: str, refiner_ckpt: str, positive: str, negative: str = \"\", *, base_steps: int = 20, refiner_steps: int = 5, cfg: float = 7.0, seed: int = 0, width: int = 1024, height: int = 1024) -> dict"
description: "Construye el dict de un workflow ComfyUI SDXL base+refiner en API format: dos KSamplerAdvanced encadenados que comparten el total de pasos. El base arranca el ruido y devuelve el latente con ruido sobrante (return_with_leftover_noise=enable), el refiner lo recoge (add_noise=disable) y lo termina. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, image-generation, sdxl, refiner, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base_ckpt
desc: "Nombre del checkpoint base SDXL tal como lo ve el servidor (ej. 'sd_xl_base_1.0.safetensors'). En CheckpointLoaderSimple."
- name: refiner_ckpt
desc: "Nombre del checkpoint refiner SDXL (ej. 'sd_xl_refiner_1.0.safetensors')."
- name: positive
desc: "Prompt positivo: lo que se quiere ver. Se usa para el CLIP del base y el del refiner."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: base_steps
desc: "Pasos que ejecuta el sampler base (del 0 a base_steps). keyword-only."
- name: refiner_steps
desc: "Pasos que ejecuta el refiner (de base_steps al total). El total es base_steps + refiner_steps. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale (compartido por ambos samplers). keyword-only."
- name: seed
desc: "Semilla de ruido (compartida por ambos samplers). keyword-only."
- name: width
desc: "Ancho del latente/imagen en px (SDXL nativo 1024). keyword-only."
- name: height
desc: "Alto del latente/imagen en px (SDXL nativo 1024). keyword-only."
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple base '4' y refiner '14', EmptyLatentImage '5', CLIPTextEncode base '6'/'7' y refiner '16'/'17', KSamplerAdvanced base '3' y refiner '15', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_sdxl_refiner_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
wf = comfyui_build_sdxl_refiner_workflow(
base_ckpt="sd_xl_base_1.0.safetensors",
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
positive="a majestic lion on a cliff at sunset, ultra detailed",
negative="blurry, low quality",
base_steps=20, refiner_steps=5,
seed=42,
)
# wf["3"]["inputs"]["steps"] == 25 # total = base + refiner
# wf["3"]["inputs"]["end_at_step"] == 20 # base corta en base_steps
# wf["15"]["inputs"]["start_at_step"] == 20 # refiner arranca ahi
# wf["15"]["inputs"]["latent_image"] == ["3", 0] # encadenado del base
```
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
`*` keyword-only); usa el import o un heredoc.
## Cuando usarla
Cuando uses el pipeline oficial SDXL de dos etapas (checkpoint base + checkpoint
refiner) para pulir el detalle final. Si solo tienes un checkpoint SDXL completo
(sin refiner separado) usa `comfyui_build_txt2img_workflow` con width/height 1024
— el refiner separado solo merece la pena con `sd_xl_refiner_*`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
- Los dos KSamplerAdvanced comparten `steps` = base_steps + refiner_steps. El
base va de 0 a base_steps con `return_with_leftover_noise=enable` (no decodifica);
el refiner va de base_steps a 10000 (= "hasta el final") con `add_noise=disable`.
- El VAE de salida es el del refiner (`["14", 2]`). Ambos checkpoints SDXL traen
el mismo VAE, asi que el resultado no cambia; para un VAE externo cambia esa
conexion en el nodo '8'.
- SDXL es nativo a 1024x1024: bajar mucho la resolucion degrada el resultado.
- Es pura: NO valida que los checkpoints existan en el servidor. Valida antes con
`comfyui_validate_workflow` (necesitas ambos: base y refiner descargados).
@@ -0,0 +1,147 @@
"""Construye un workflow ComfyUI SDXL base+refiner en API format.
SDXL genera en dos etapas: un checkpoint base produce el latente con la mayor
parte de los pasos y un checkpoint refiner termina los ultimos pasos para pulir
el detalle. Se encadenan dos KSamplerAdvanced compartiendo el numero total de
pasos: el base arranca el ruido y devuelve el latente con ruido sobrante
(return_with_leftover_noise=enable, no decodifica), y el refiner lo recoge
(add_noise=disable) y lo lleva al final.
Cadena de nodos: CheckpointLoaderSimple base + CheckpointLoaderSimple refiner +
EmptyLatentImage + 4 CLIPTextEncode (positivo/negativo por cada CLIP) ->
KSamplerAdvanced base -> KSamplerAdvanced refiner -> VAEDecode -> SaveImage.
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_sdxl_refiner_workflow(
base_ckpt: str,
refiner_ckpt: str,
positive: str,
negative: str = "",
*,
base_steps: int = 20,
refiner_steps: int = 5,
cfg: float = 7.0,
seed: int = 0,
width: int = 1024,
height: int = 1024,
) -> dict:
"""Construye el dict de un workflow SDXL base+refiner (dos KSamplerAdvanced).
Args:
base_ckpt: nombre del checkpoint base SDXL tal como lo ve el servidor
ComfyUI (ej. "sd_xl_base_1.0.safetensors"). En CheckpointLoaderSimple.
refiner_ckpt: nombre del checkpoint refiner SDXL
(ej. "sd_xl_refiner_1.0.safetensors").
positive: prompt positivo (lo que se quiere ver en la imagen). Se usa
tanto para el CLIP del base como para el del refiner.
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
base_steps: pasos que ejecuta el sampler base (del 0 a base_steps).
keyword-only.
refiner_steps: pasos que ejecuta el refiner (de base_steps al total).
El total de pasos es base_steps + refiner_steps. keyword-only.
cfg: classifier-free guidance scale (compartido). keyword-only.
seed: semilla de ruido (compartida por ambos samplers). keyword-only.
width: ancho del latente/imagen en px (SDXL nativo 1024). keyword-only.
height: alto del latente/imagen en px (SDXL nativo 1024). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids y cada valor tiene class_type + inputs.
"""
total_steps = base_steps + refiner_steps
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": base_ckpt},
},
"14": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": refiner_ckpt},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"16": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["14", 1]},
},
"17": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["14", 1]},
},
"3": {
"class_type": "KSamplerAdvanced",
"inputs": {
"add_noise": "enable",
"noise_seed": seed,
"steps": total_steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": 0,
"end_at_step": base_steps,
"return_with_leftover_noise": "enable",
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"15": {
"class_type": "KSamplerAdvanced",
"inputs": {
"add_noise": "disable",
"noise_seed": seed,
"steps": total_steps,
"cfg": cfg,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": base_steps,
"end_at_step": 10000,
"return_with_leftover_noise": "disable",
"model": ["14", 0],
"positive": ["16", 0],
"negative": ["17", 0],
"latent_image": ["3", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["15", 0], "vae": ["14", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_sdxl", "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_sdxl_refiner_workflow(
base_ckpt="sd_xl_base_1.0.safetensors",
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
positive="a majestic lion on a cliff at sunset, ultra detailed",
negative="blurry, low quality",
base_steps=20,
refiner_steps=5,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,87 @@
---
name: comfyui_build_txt2img_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_txt2img_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, steps: int = 20, cfg: float = 7.0, width: int = 512, height: int = 512, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"comfy\") -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]) para SD1.5/SDXL: CheckpointLoaderSimple -> CLIPTextEncode x2 + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O."
tags: [comfyui, ml, image-generation, txt2img, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'v1-5-pruned-emaonly-fp16.safetensors'). Debe estar en la lista que devuelve comfyui_object_info para CheckpointLoaderSimple."
- name: positive
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
- name: steps
desc: "Pasos de sampling del KSampler. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px, multiplo de 8. keyword-only."
- name: height
desc: "Alto del latente/imagen en px, multiplo de 8. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format con node_ids '3'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_txt2img_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
# wf["3"]["class_type"] == "KSampler"
# wf["3"]["inputs"]["model"] == ["4", 0] # conexion al CheckpointLoader
# wf["9"]["class_type"] == "SaveImage"
```
O lanzable directo con: `./fn run comfyui_build_txt2img_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Antes de enviar una generacion txt2img a ComfyUI: construye aqui el dict del
workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que necesites un
txt2img basico sin tener que escribir el grafo de nodos a mano. Para workflows
mas complejos (img2img, ControlNet, upscalers) construye el dict tu mismo o
extiende esta funcion con un builder hermano.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se puede pegar en la UI tal cual; es el formato que acepta POST
/prompt.
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
servidor. Si no existe, ComfyUI rechaza el workflow con HTTP 400 al enviarlo
(no aqui — esta funcion es pura y no valida contra el servidor).
- `width`/`height` deben ser multiplos de 8 o el KSampler fallara en el
servidor.
- Asume que el checkpoint trae VAE embebido (VAEDecode usa `["4", 2]`, la salida
VAE del CheckpointLoaderSimple). Para un VAE externo cambia esa conexion.
@@ -0,0 +1,103 @@
"""Construye un workflow ComfyUI txt2img en "API format" (dict de nodos numerados).
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_txt2img_workflow(
ckpt_name: str,
positive: str,
negative: str = "",
*,
steps: int = 20,
cfg: float = 7.0,
width: int = 512,
height: int = 512,
seed: int = 0,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "comfy",
) -> dict:
"""Construye el dict del workflow txt2img basico para SD1.5 / SDXL.
Cadena de nodos: CheckpointLoaderSimple -> CLIPTextEncode (positivo y
negativo) + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage.
Args:
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
(ej. "v1-5-pruned-emaonly-fp16.safetensors"). Debe estar entre los
que devuelve comfyui_object_info en CheckpointLoaderSimple.
positive: prompt positivo (lo que se quiere ver en la imagen).
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
steps: pasos de sampling del KSampler.
cfg: classifier-free guidance scale.
width: ancho del latente/imagen en px (multiplo de 8).
height: alto del latente/imagen en px (multiplo de 8).
seed: semilla del KSampler (0 = determinista; cambia para variar).
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m").
scheduler: scheduler del sampler (ej. "normal", "karras").
filename_prefix: prefijo del PNG generado por SaveImage en output/.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids ("3".."9") y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,68 @@
---
name: comfyui_build_upscale_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_upscale_workflow(image: str, *, model_name: str = \"4x-UltraSharp.pth\", method: str = \"model\") -> dict"
description: "Construye el dict de un workflow ComfyUI de upscale en API format. method='model' usa UpscaleModelLoader + ImageUpscaleWithModel (ESRGAN, alta calidad); method='latent' usa ImageScaleBy (reescalado de pixel x2 sin modelo). Pura, sin red ni I/O."
tags: [comfyui, ml, upscale, esrgan, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image
desc: "Nombre del archivo de imagen dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
- name: model_name
desc: "Nombre del modelo de upscale en models/upscale_models/ (ej. '4x-UltraSharp.pth'). Solo se usa con method='model'. keyword-only."
- name: method
desc: "'model' (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel) o 'latent' (reescalado de pixel x2 con ImageScaleBy, sin modelo). keyword-only."
output: "dict en API format. Con method='model': LoadImage '10' + UpscaleModelLoader '12' + ImageUpscaleWithModel '13' + SaveImage '9'. Con method='latent': LoadImage '10' + ImageScaleBy '13' + SaveImage '9'. Listo para comfyui_submit_workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_upscale_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_upscale_workflow import comfyui_build_upscale_workflow
# Upscale con modelo ESRGAN (necesita el .pth en models/upscale_models/)
wf = comfyui_build_upscale_workflow("render.png", model_name="4x-UltraSharp.pth")
# wf["12"]["class_type"] == "UpscaleModelLoader"
# wf["13"]["inputs"]["upscale_model"] == ["12", 0]
# Upscale rapido sin modelo (reescalado de pixel x2)
wf_latent = comfyui_build_upscale_workflow("render.png", method="latent")
# wf_latent["13"]["class_type"] == "ImageScaleBy"
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Cuando quieras ampliar una imagen ya generada. Usa `method="model"` (ESRGAN) para
mejor calidad si tienes un upscaler en `models/upscale_models/` (ej. 4x-UltraSharp);
usa `method="latent"` para un reescalado rapido sin descargar nada. Pega la salida
de un txt2img/img2img como `image` en el input/ del servidor.
## Gotchas
- `method="latent"` NO es un upscale en espacio latente real (eso requiere un
checkpoint+VAE para encode/decode, que esta firma no recibe). Usa `ImageScaleBy`
= reescalado de pixel con lanczos x2. Es honesto: barato y sin modelo, pero no
recupera detalle como un ESRGAN. Para latent-upscale real construye un workflow
con checkpoint + VAEEncode + LatentUpscale + VAEDecode.
- Con `method="model"`, `model_name` debe existir en `models/upscale_models/`. Si
no, ComfyUI rechaza el workflow al enviarlo (HTTP 400). Valida antes con
`comfyui_validate_workflow`.
- `image` debe existir en la carpeta `input/` del servidor.
- Es pura: no valida contra el servidor.
@@ -0,0 +1,87 @@
"""Construye un workflow ComfyUI de upscale en API format (dict de nodos numerados).
Dos modos:
- method="model": upscale con modelo ESRGAN (UpscaleModelLoader +
ImageUpscaleWithModel). Calidad alta; necesita un modelo en
models/upscale_models/ (ej. "4x-UltraSharp.pth").
- method="latent": reescalado en espacio de pixel con ImageScaleBy (x2, sin
modelo ni checkpoint). Upscale rapido y barato.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_upscale_workflow(
image: str,
*,
model_name: str = "4x-UltraSharp.pth",
method: str = "model",
) -> dict:
"""Construye el dict de un workflow de upscale para una imagen cargada.
Args:
image: nombre del archivo de imagen dentro de la carpeta input/ del
servidor ComfyUI (lo que carga el nodo LoadImage).
model_name: nombre del modelo de upscale en models/upscale_models/
(ej. "4x-UltraSharp.pth"). Solo se usa con method="model".
keyword-only.
method: "model" (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel)
o "latent" (reescalado de pixel x2 con ImageScaleBy, sin modelo).
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow.
Raises:
ValueError: si method no es "model" ni "latent".
"""
if method not in ("model", "latent"):
raise ValueError(
f"comfyui_build_upscale_workflow: method invalido {method!r}; "
"usa 'model' o 'latent'."
)
load = {
"10": {"class_type": "LoadImage", "inputs": {"image": image}},
}
if method == "model":
return {
**load,
"12": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": model_name},
},
"13": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["12", 0], "image": ["10", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "comfy_upscale",
"images": ["13", 0],
},
},
}
# method == "latent": reescalado de pixel x2 sin modelo
return {
**load,
"13": {
"class_type": "ImageScaleBy",
"inputs": {
"upscale_method": "lanczos",
"scale_by": 2.0,
"image": ["10", 0],
},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "comfy_upscale", "images": ["13", 0]},
},
}
if __name__ == "__main__":
import json
print(json.dumps(comfyui_build_upscale_workflow("example.png"), indent=2))
print(json.dumps(comfyui_build_upscale_workflow("example.png", method="latent"), indent=2))
@@ -0,0 +1,85 @@
---
name: comfyui_download_model
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_download_model(url: str, dest_subdir: str = 'checkpoints', *, comfyui_dir: str = '~/ComfyUI', filename: str | None = None, token: str | None = None, overwrite: bool = False, timeout_s: float = 1800.0) -> dict"
description: "Descarga un checkpoint/LoRA/VAE a <comfyui_dir>/models/<dest_subdir>/<filename> por HTTP siguiendo redirects. Soporta Civitai (token via ?token= y header Authorization Bearer) y HuggingFace (URL directa). Valida que la respuesta NO sea HTML de error y que un .safetensors tenga cabecera valida, asi no deja modelos falsos de 2 KB. Impura: red (HTTP GET) + escritura en disco. Solo stdlib."
tags: [comfyui, ml, image-generation, stable-diffusion, http, download, models, civitai, huggingface]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "struct", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: url
desc: "URL directa de descarga (Civitai api/download/models/<versionId>, HuggingFace resolve, o cualquier HTTP que sirva el binario)."
- name: dest_subdir
desc: "Subcarpeta dentro de models/ (checkpoints, loras, vae, controlnet, ...). Default 'checkpoints'."
- name: comfyui_dir
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'."
- name: filename
desc: "Nombre destino. None lo deriva del Content-Disposition de la respuesta o del path de la URL."
- name: token
desc: "Token de API (Civitai). Se añade como ?token= y como header Authorization Bearer. None lo omite. No hardcodear secretos: pasar desde pass/vault."
- name: overwrite
desc: "Si False y el destino ya existe, no descarga y devuelve error. Default False."
- name: timeout_s
desc: "Timeout de la peticion HTTP en segundos. Default 1800 (30 min, modelos grandes)."
output: "dict {ok: bool, path: str, size_bytes: int, error: str}. ok False si la respuesta era HTML de error, si un .safetensors no valida su cabecera, si la descarga es < 1 KB, o si fallo red/escritura. En esos casos NO deja basura en disco (limpia el .part)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_download_model.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_download_model import comfyui_download_model
# Civitai (token desde pass, nunca hardcodeado):
import subprocess
token = subprocess.run(["pass", "civitai/api-token"], capture_output=True, text=True).stdout.strip() or None
out = comfyui_download_model(
"https://civitai.com/api/download/models/128713",
dest_subdir="checkpoints",
token=token,
)
print(out["ok"], out["path"], out["size_bytes"])
# HuggingFace (URL directa resolve), sin token:
out = comfyui_download_model(
"https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors",
dest_subdir="vae",
)
```
## Cuando usarla
Cuando necesitas un modelo que ComfyUI no tiene aun: lo bajas a la carpeta
correcta y luego llamas `comfyui_refresh_nodes_ui` para que aparezca en los
combos de la UI sin recargar. Resuelve el sitio (`models/<dest_subdir>/`) y el
nombre por ti, y rechaza descargas que en realidad son paginas de error.
## Gotchas
- **Civitai exige login para muchos modelos**: sin `token` valido, Civitai
responde con HTML (login/Cloudflare). La funcion lo detecta (content-type +
sniff de los primeros bytes) y devuelve `ok=False` SIN guardar el HTML. Si ves
ese error, falta o caduco el token.
- La validacion de cabecera safetensors solo aplica a nombres `.safetensors`. Un
`.ckpt`/`.pt`/`.bin` se valida solo por content-type, sniff HTML y tamaño minimo
(1 KB). Para `.safetensors` ademas se comprueba la cabecera (8 bytes LE de
longitud + `{`).
- Descarga a `<destino>.part` y solo hace `os.replace` al destino final tras
validar: una descarga corrupta o HTML no deja archivo final.
- `overwrite=False` (default) NO re-descarga si el archivo ya existe: devuelve
`ok=False` con el path existente. Pasa `overwrite=True` para forzar.
- Modelos grandes (varios GB) tardan; sube `timeout_s` si hace falta. No abuses
del disco: comprueba espacio antes de bajar checkpoints SDXL (~6-7 GB).
@@ -0,0 +1,194 @@
"""Descarga un checkpoint / LoRA / VAE a la carpeta correcta de ComfyUI.
Descarga por HTTP a `<comfyui_dir>/models/<dest_subdir>/<filename>` siguiendo
redirects. Soporta Civitai (`https://civitai.com/api/download/models/<versionId>`,
token opcional via `?token=` y header `Authorization: Bearer`) y HuggingFace (URL
directa de resolve). Antes de aceptar el archivo VALIDA que la respuesta no sea
una pagina HTML de error (Cloudflare, login wall, 404 estilizado) y que, si el
nombre termina en `.safetensors`, tenga una cabecera de safetensors valida. Asi
no deja "modelos" que en realidad son HTML de 2 KB.
Funcion impura: hace red (HTTP GET) y escribe en disco. Solo stdlib.
"""
import json
import os
import struct
import urllib.error
import urllib.parse
import urllib.request
_HTML_SNIFF = (b"<!doctype", b"<html", b"<head", b"<?xml")
def _derive_filename(url: str, content_disposition: str) -> str:
"""Deriva el nombre de archivo del Content-Disposition o, si no, de la URL."""
if content_disposition:
# filename="x" | filename=x | filename*=UTF-8''x
for part in content_disposition.split(";"):
part = part.strip()
for key in ("filename*=", "filename="):
if part.lower().startswith(key):
raw = part[len(key):].strip().strip('"')
if "''" in raw: # RFC 5987: UTF-8''<pct-encoded>
raw = raw.split("''", 1)[1]
name = urllib.parse.unquote(os.path.basename(raw))
if name:
return name
name = os.path.basename(urllib.parse.urlparse(url).path)
return name or "model.bin"
def _is_valid_safetensors(path: str) -> bool:
"""True si el archivo tiene cabecera de safetensors coherente.
Formato: 8 bytes little-endian con la longitud N del header JSON, seguidos de
N bytes que empiezan por '{'. Rechaza HTML/errores disfrazados de .safetensors.
"""
try:
size = os.path.getsize(path)
if size < 9:
return False
with open(path, "rb") as fh:
n = struct.unpack("<Q", fh.read(8))[0]
if n <= 0 or n > size - 8 or n > 100_000_000:
return False
return fh.read(1) == b"{"
except Exception: # noqa: BLE001 — archivo ilegible = invalido
return False
def comfyui_download_model(
url: str,
dest_subdir: str = "checkpoints",
*,
comfyui_dir: str = "~/ComfyUI",
filename: str | None = None,
token: str | None = None,
overwrite: bool = False,
timeout_s: float = 1800.0,
) -> dict:
"""Descarga un modelo a `<comfyui_dir>/models/<dest_subdir>/<filename>`.
Args:
url: URL directa de descarga (Civitai api/download, HuggingFace resolve,
o cualquier HTTP que sirva el binario).
dest_subdir: subcarpeta dentro de `models/` (checkpoints, loras, vae,
controlnet, ...). Default "checkpoints".
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
filename: nombre destino del archivo. Si None, se deriva del
Content-Disposition de la respuesta o del path de la URL.
token: token de API (Civitai). Se añade como `?token=` y como header
`Authorization: Bearer <token>`. None lo omite.
overwrite: si False y el destino ya existe, no descarga y devuelve error.
timeout_s: timeout de la peticion HTTP en segundos.
Returns:
dict {ok: bool, path: str, size_bytes: int, error: str}. ok False si la
respuesta era HTML de error, si un .safetensors no valida su cabecera, o
si fallo la red/escritura. En esos casos no deja basura en disco.
"""
base = os.path.expanduser(comfyui_dir)
dest_dir = os.path.join(base, "models", dest_subdir)
req_url = url
headers = {"User-Agent": "fn-registry/comfyui_download_model"}
if token:
sep = "&" if "?" in req_url else "?"
req_url = f"{req_url}{sep}token={urllib.parse.quote(token)}"
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(req_url, headers=headers)
tmp_path = None
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
content_type = resp.headers.get("Content-Type", "")
disp = resp.headers.get("Content-Disposition", "")
name = filename or _derive_filename(resp.geturl(), disp)
os.makedirs(dest_dir, exist_ok=True)
final_path = os.path.join(dest_dir, name)
if os.path.exists(final_path) and not overwrite:
return {
"ok": False,
"path": final_path,
"size_bytes": os.path.getsize(final_path),
"error": f"ya existe (overwrite=False): {final_path}",
}
# Rechazo temprano por content-type HTML.
if "text/html" in content_type.lower():
return {
"ok": False,
"path": "",
"size_bytes": 0,
"error": (
f"la respuesta es HTML (Content-Type: {content_type}), "
"no un binario de modelo. Revisa la URL/token."
),
}
tmp_path = final_path + ".part"
first = resp.read(512)
# Sniff de los primeros bytes: HTML aunque el content-type mienta.
low = first.lower().lstrip()
if any(low.startswith(sig) for sig in _HTML_SNIFF):
return {
"ok": False,
"path": "",
"size_bytes": 0,
"error": "la respuesta empieza con HTML (pagina de error/login), no un modelo.",
}
size = 0
with open(tmp_path, "wb") as fh:
fh.write(first)
size += len(first)
while True:
chunk = resp.read(1024 * 256)
if not chunk:
break
fh.write(chunk)
size += len(chunk)
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:300]
_cleanup(tmp_path)
return {"ok": False, "path": "", "size_bytes": 0,
"error": f"HTTP {exc.code} en {url}: {body}"}
except Exception as exc: # noqa: BLE001 — red/DNS/escritura
_cleanup(tmp_path)
return {"ok": False, "path": "", "size_bytes": 0,
"error": f"fallo descargando {url}: {exc}"}
# Validacion de tamaño minimo (una pagina de error suele ser < 2 KB).
if size < 1024:
_cleanup(tmp_path)
return {"ok": False, "path": "", "size_bytes": size,
"error": f"descarga sospechosamente pequeña ({size} bytes); probable error, no un modelo."}
# Validacion de cabecera safetensors si aplica.
if name.endswith(".safetensors") and not _is_valid_safetensors(tmp_path):
_cleanup(tmp_path)
return {"ok": False, "path": "", "size_bytes": size,
"error": f"{name} no tiene una cabecera safetensors valida; descarga corrupta o HTML disfrazado."}
os.replace(tmp_path, final_path)
return {"ok": True, "path": final_path, "size_bytes": size, "error": ""}
def _cleanup(path: str | None) -> None:
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
import sys
out = comfyui_download_model(
sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8188/",
dest_subdir="checkpoints",
filename="smoke_fake.safetensors",
)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,66 @@
---
name: comfyui_fetch_output_image
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_output_image(filename: str, *, subfolder: str = \"\", type_: str = \"output\", server: str = \"127.0.0.1:8188\", dest_dir: str = \".\", timeout: float = 60.0) -> dict"
description: "Descarga un PNG generado por ComfyUI via GET /view?filename=&subfolder=&type= a disco local. comfyui_wait_result solo devuelve metadata (filename/subfolder/type); esta funcion baja el archivo real. Impura: HTTP GET + escritura en disco, solo stdlib."
tags: [comfyui, ml, image-generation, download, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: filename
desc: "Nombre del archivo en el servidor (ej. 'comfy_00001_.png'), tal como lo reporta comfyui_wait_result en outputs[node].images[].filename."
- name: subfolder
desc: "Subcarpeta dentro de la carpeta del servidor (vacia por defecto). keyword-only."
- name: type_
desc: "Tipo de carpeta del servidor: 'output', 'temp' o 'input'. keyword-only."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: dest_dir
desc: "Directorio local donde guardar la imagen; se crea si no existe. keyword-only."
- name: timeout
desc: "Timeout de la peticion HTTP en segundos. keyword-only."
output: "dict {ok, path, size_bytes, error}. path = ruta local del PNG guardado, size_bytes = bytes descargados. Si falla, ok=False y error explica (HTTP/conexion/escritura)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_fetch_output_image.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
# Tras comfyui_submit_workflow + comfyui_wait_result, baja el PNG al disco
res = comfyui_fetch_output_image("comfy_00001_.png", dest_dir="/tmp/comfy_out")
# res == {"ok": True, "path": "/tmp/comfy_out/comfy_00001_.png", "size_bytes": 372027, "error": ""}
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Despues de generar una imagen (submit + wait), cuando necesites el PNG real en
disco (no solo su nombre): para abrirlo, mostrarlo, post-procesarlo o moverlo a
un vault. Toma `filename`/`subfolder`/`type` directo de la entrada `images[]` que
devuelve `comfyui_wait_result` por nodo SaveImage.
## Gotchas
- Impura: hace HTTP GET al servidor y escribe en disco. Requiere el servidor vivo.
- `type_` debe coincidir con la carpeta real: SaveImage escribe en "output",
PreviewImage en "temp". Si pasas el type equivocado, el servidor responde 404.
- El nombre local es `basename(filename)` dentro de `dest_dir` (no recrea la
estructura de subfolder en local).
- No reintenta: si el servidor esta reiniciandose, devuelve error de conexion;
reintenta tu desde el caller.
@@ -0,0 +1,71 @@
"""Descarga un PNG generado por ComfyUI via GET /view a disco local.
comfyui_wait_result devuelve solo metadata (node_id -> {images: [{filename,
subfolder, type}]}); esta funcion baja el archivo real al disco local para
poder abrirlo, mostrarlo o procesarlo.
Impura: red (HTTP GET) + escritura en disco. Solo stdlib (urllib, os).
"""
import os
import urllib.error
import urllib.parse
import urllib.request
def comfyui_fetch_output_image(
filename: str,
*,
subfolder: str = "",
type_: str = "output",
server: str = "127.0.0.1:8188",
dest_dir: str = ".",
timeout: float = 60.0,
) -> dict:
"""Baja una imagen del servidor ComfyUI a un directorio local.
Args:
filename: nombre del archivo en el servidor (ej. "comfy_00001_.png"),
tal como lo reporta comfyui_wait_result en outputs[node].images.
subfolder: subcarpeta dentro de la carpeta del servidor (vacia por
defecto). keyword-only.
type_: tipo de carpeta del servidor: "output", "temp" o "input".
keyword-only.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio local donde guardar la imagen; se crea si no existe.
keyword-only.
timeout: timeout de la peticion HTTP en segundos. keyword-only.
Returns:
dict {ok, path, size_bytes, error}. path = ruta local del PNG guardado;
size_bytes = tamano descargado. Si falla, ok=False y error explica.
"""
qs = urllib.parse.urlencode(
{"filename": filename, "subfolder": subfolder, "type": type_}
)
url = f"http://{server}/view?{qs}"
try:
with urllib.request.urlopen(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": "", "size_bytes": 0,
"error": f"HTTP {exc.code} en {url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "path": "", "size_bytes": 0,
"error": f"no se pudo conectar a {url}: {exc.reason}"}
try:
os.makedirs(dest_dir, exist_ok=True)
out_path = os.path.join(dest_dir, os.path.basename(filename))
with open(out_path, "wb") as f:
f.write(blob)
except OSError as exc:
return {"ok": False, "path": "", "size_bytes": 0,
"error": f"no se pudo escribir en {dest_dir!r}: {exc}"}
return {"ok": True, "path": out_path, "size_bytes": len(blob), "error": ""}
if __name__ == "__main__":
import json
res = comfyui_fetch_output_image("comfy_00001_.png", dest_dir="/tmp")
print(json.dumps(res, indent=2))
@@ -0,0 +1,68 @@
---
name: comfyui_fetch_output_mesh
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_output_mesh(prompt_id: str, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, timeout: float = 120.0) -> dict"
description: "Localiza y descarga la malla 3D producida por un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_image pero para mallas: el nodo SaveGLB expone su salida en GET /history/{prompt_id} bajo la clave '3d' (no 'images'). Localiza el primer .glb/.obj/.ply/.gltf/.fbx/.stl, lo baja via GET /view y opcionalmente lo escribe en dest. Impura: HTTP GET + escritura en disco, solo stdlib."
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, download, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: prompt_id
desc: "id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo SaveGLB ya termino (usa comfyui_wait_result antes si dudas)."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: dest
desc: "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."
- name: timeout
desc: "Timeout de cada peticion HTTP en segundos. keyword-only."
output: "dict {ok, path, format, bytes, error}. path = ruta local del archivo de malla guardado, format = extension sin punto (ej. 'glb'), bytes = bytes descargados. Si falla, ok=False y error explica (sin malla en history, HTTP, conexion o escritura)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_fetch_output_mesh.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_fetch_output_mesh import comfyui_fetch_output_mesh
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow imagen->3D,
# baja el .glb al disco (el SaveGLB lo expone en /history bajo la clave "3d").
res = comfyui_fetch_output_mesh("2817f111-e21b-4672-95e7-5bec4314c4a7", dest="/tmp/meshes")
# res == {"ok": True, "path": "/tmp/meshes/3d_robot_mesh_00001_.glb",
# "format": "glb", "bytes": 60051544, "error": ""}
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Despues de reconstruir una malla 3D (submit + wait de un workflow Hunyuan3D),
cuando necesites el archivo .glb/.obj/.ply real en disco (no solo su nombre): para
abrirlo en un visor, post-procesarlo (decimar, recolorear) o moverlo a un vault.
Para el flujo completo desde una imagen en disco usa el pipeline
`comfyui_image_to_3d_oneshot`, que ya llama a esta funcion al final.
## Gotchas
- Impura: hace HTTP GET a /history y /view y escribe en disco. Requiere el server
vivo y que el prompt YA haya terminado (usa `comfyui_wait_result` antes).
- El SaveGLB expone la malla bajo la clave `"3d"` en los outputs, NO bajo
`"images"` — por eso `comfyui_fetch_output_image` no sirve para mallas.
- El history se purga al reiniciar el server: si el prompt ya no esta, devuelve
`ok=False` con "no esta en /history". No reintenta; reintenta tu desde el caller.
- Toma el PRIMER archivo de malla que encuentra (prioriza la clave "3d"). Si un
workflow exporta varios formatos, baja solo uno; para los demas, llama otra vez
o usa GET /view con el filename concreto.
- `dest` se interpreta: None -> cwd; directorio -> dentro; archivo -> esa ruta.
@@ -0,0 +1,147 @@
"""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))
@@ -0,0 +1,68 @@
---
name: comfyui_import_workflow_json
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_import_workflow_json(source: str, *, server: str = \"127.0.0.1:8188\", timeout: float = 15.0) -> dict"
description: "Lee un workflow ComfyUI desde una URL (http/https) o un path local y lo normaliza a API format. Si viene en formato UI graph ({nodes, links}) lo convierte a API format usando /object_info para mapear los widgets; si ya es API format lo devuelve tal cual. Compone comfyui_object_info. Impura: HTTP GET / lectura de disco."
tags: [comfyui, ml, import, workflow, stable-diffusion]
uses_functions: [comfyui_object_info_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: source
desc: "URL http(s) de un JSON de workflow (OpenArt, ComfyWorkflows, raw GitHub...) o ruta de un archivo local."
- name: server
desc: "host:port de ComfyUI usado SOLO para mapear los valores de widget cuando la fuente viene en formato UI graph. keyword-only."
- name: timeout
desc: "Timeout HTTP en segundos. keyword-only."
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_import_workflow_json import comfyui_import_workflow_json
# Desde un archivo local en API format (passthrough)
res = comfyui_import_workflow_json("/tmp/mi_workflow.json")
# res == {"ok": True, "workflow": {...}, "format_detected": "api", "error": ""}
# Desde una URL (descarga + normaliza si viene como UI graph)
res2 = comfyui_import_workflow_json("https://raw.githubusercontent.com/user/repo/main/wf.json")
# res2["format_detected"] in ("api", "ui_graph")
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Cuando quieras lanzar un workflow ajeno (de OpenArt, ComfyWorkflows, raw GitHub o
un .json local) por la API. Devuelve siempre API format, listo para
`comfyui_validate_workflow` + `comfyui_submit_workflow`. Para workflows embebidos
en un PNG usa `comfyui_import_workflow_png`.
## Gotchas
- Impura: HTTP GET si `source` es URL, lectura de disco si es path. Lectura/JSON
invalido devuelven `{ok: False, error: ...}` (no lanza).
- La conversion UI graph -> API es best-effort: las CONEXIONES entre nodos se
reconstruyen siempre, pero el mapeo de los valores de widget (steps, cfg, texto)
necesita `/object_info` del servidor. Si el servidor esta caido, los widgets del
UI graph NO se mapean (quedan fuera) — valida el resultado antes de encolar.
- El orden de widgets en object_info se asume = orden de widgets_values del UI
graph; nodos custom muy raros pueden desalinearse.
- API format se detecta porque todos los valores top-level son dicts con
`class_type`; UI graph por la clave `nodes`. Otros JSON dan
"formato no reconocido".
@@ -0,0 +1,137 @@
"""Importa un workflow ComfyUI desde una URL (http/https) o un path local.
Detecta el formato:
- API format: dict {node_id: {class_type, inputs}} -> se devuelve tal cual.
- UI graph: dict {nodes, links, ...} (lo que exporta "Save" en la UI) -> se
normaliza a API format. La normalizacion de los valores de widget necesita el
catalogo /object_info del servidor; si el servidor responde, los widgets se
mapean por nombre; si no, solo se conservan las conexiones entre nodos.
Compone comfyui_object_info para el mapeo de widgets del UI graph.
Impura: red (HTTP GET si source es URL) + lectura de disco. Solo stdlib.
"""
import json
import os
import sys
import urllib.error
import urllib.request
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_object_info import comfyui_object_info # noqa: E402
def comfyui_import_workflow_json(
source: str,
*,
server: str = "127.0.0.1:8188",
timeout: float = 15.0,
) -> dict:
"""Lee un workflow JSON y lo normaliza a API format.
Args:
source: URL http(s) de un JSON de workflow, o ruta de un archivo local.
server: host:port de ComfyUI usado solo para mapear los widgets cuando
la fuente viene en formato UI graph. keyword-only.
timeout: timeout HTTP en segundos. keyword-only.
Returns:
dict {ok, workflow, format_detected, error}. format_detected es "api",
"ui_graph" o "". Si falla, ok=False y error explica el motivo.
"""
try:
if source.startswith(("http://", "https://")):
with urllib.request.urlopen(source, timeout=timeout) as resp:
raw = resp.read()
else:
with open(source, "rb") as f:
raw = f.read()
data = json.loads(raw)
except (urllib.error.URLError, OSError) as exc:
return {"ok": False, "workflow": {}, "format_detected": "",
"error": f"no se pudo leer {source!r}: {exc}"}
except json.JSONDecodeError as exc:
return {"ok": False, "workflow": {}, "format_detected": "",
"error": f"JSON invalido en {source!r}: {exc}"}
if not isinstance(data, dict):
return {"ok": False, "workflow": {}, "format_detected": "",
"error": "el JSON no es un objeto de workflow"}
# API format: todos los valores son dicts con class_type
if data and all(isinstance(v, dict) and "class_type" in v for v in data.values()):
return {"ok": True, "workflow": data, "format_detected": "api", "error": ""}
# UI graph: tiene la clave "nodes"
if "nodes" in data:
obj_info = None
try:
obj_info = comfyui_object_info(server=server, timeout=min(timeout, 5.0))
except Exception:
obj_info = None
api = _ui_graph_to_api(data, obj_info)
return {"ok": True, "workflow": api, "format_detected": "ui_graph", "error": ""}
return {"ok": False, "workflow": {}, "format_detected": "",
"error": "formato de workflow no reconocido (ni API ni UI graph)"}
def _ui_graph_to_api(graph: dict, obj_info) -> dict:
"""Convierte un UI graph de ComfyUI a API format (best-effort)."""
nodes = graph.get("nodes", []) or []
links = graph.get("links", []) or []
# link_id -> (src_node_id, src_slot)
link_src = {}
for lk in links:
if isinstance(lk, list) and len(lk) >= 5:
link_src[lk[0]] = (str(lk[1]), lk[2])
api = {}
for node in nodes:
ctype = node.get("type")
if ctype is None:
continue
nid = str(node.get("id"))
inputs = {}
connected = set()
for inp in node.get("inputs", []) or []:
name = inp.get("name")
link = inp.get("link")
if name is not None and link is not None and link in link_src:
src_node, src_slot = link_src[link]
inputs[name] = [src_node, src_slot]
connected.add(name)
widgets = node.get("widgets_values")
if isinstance(widgets, dict):
inputs.update(widgets)
elif isinstance(widgets, list) and widgets:
for name, val in zip(_widget_input_names(ctype, obj_info, connected), widgets):
inputs[name] = val
api[nid] = {"class_type": ctype, "inputs": inputs}
return api
def _widget_input_names(ctype, obj_info, connected) -> list:
"""Nombres de inputs que son widgets (no conexiones), en orden, via object_info."""
if not obj_info or ctype not in obj_info:
return []
spec = obj_info[ctype].get("input", {})
names = []
for section in ("required", "optional"):
for name, decl in (spec.get(section) or {}).items():
if name in connected:
continue
t = decl[0] if isinstance(decl, list) and decl else decl
if isinstance(t, list):
names.append(name) # combo/enum => widget
elif t in ("INT", "FLOAT", "STRING", "BOOLEAN"):
names.append(name)
return names
if __name__ == "__main__":
res = comfyui_import_workflow_json("/tmp/does_not_exist.json")
print(res)
@@ -0,0 +1,66 @@
---
name: comfyui_import_workflow_png
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: png_path_or_url
desc: "Ruta local de un PNG generado por ComfyUI, o URL http(s) de un PNG (ej. de ComfyUI_examples en GitHub)."
- name: timeout
desc: "Timeout HTTP en segundos (solo si es URL). keyword-only."
output: "dict {ok, prompt, workflow, format_detected, error}. prompt = API format (dict) si existe el chunk 'prompt'; workflow = UI graph (dict) si existe el chunk 'workflow'; format_detected = chunks hallados ('prompt', 'workflow' o 'prompt+workflow'). Si no hay metadata, ok=False."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_import_workflow_png.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_import_workflow_png import comfyui_import_workflow_png
# Desde un PNG generado localmente
res = comfyui_import_workflow_png(os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"))
# res["ok"] == True
# res["format_detected"] # "prompt" (generado por API) o "prompt+workflow" (desde la UI)
# res["prompt"]["3"]["class_type"] == "KSampler"
# Desde una URL (un PNG de ComfyUI_examples trae el workflow embebido)
res2 = comfyui_import_workflow_png("https://raw.githubusercontent.com/comfyanonymous/ComfyUI_examples/master/...png")
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Cuando alguien te pase una imagen PNG de ComfyUI (de los `ComfyUI_examples`, de la
comunidad, o tuya) y quieras recuperar el workflow exacto que la genero para
relanzarlo o editarlo. El `prompt` (API format) va directo a
`comfyui_validate_workflow` + `comfyui_submit_workflow`; el `workflow` (UI graph)
puede cargarse en la UI con `comfyui_load_workflow_ui`.
## Gotchas
- Impura: HTTP GET si es URL, lectura de disco si es path. Errores devuelven
`{ok: False, error: ...}` (no lanza).
- Solo PNG: lee chunks tEXt/zTXt/iTXt. Los JPG/WebP NO llevan estos chunks (usa
otra via). Un PNG sin metadata de ComfyUI da `ok=False`.
- Los PNG generados por la API REST solo traen el chunk `prompt`; los generados
desde la UI traen ademas `workflow`. Por eso `format_detected` puede ser solo
"prompt".
- El `prompt` recuperado es API format, no el UI graph: para reabrirlo visualmente
usa el `workflow` (si existe) o reconstruye el grafo desde el API format en la UI.
@@ -0,0 +1,119 @@
"""Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI.
ComfyUI guarda en los PNG generados dos chunks de texto:
- "prompt": el workflow en API format (lo que se envio a POST /prompt).
- "workflow": el grafo de la UI (UI graph), presente si se genero desde la UI.
Lee chunks tEXt, zTXt e iTXt con stdlib (struct, zlib). Impura: red opcional (si
source es URL) + lectura de disco.
"""
import json
import struct
import urllib.error
import urllib.request
import zlib
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
Args:
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
Returns:
dict {ok, prompt, workflow, format_detected, error}:
- prompt: API format (dict) si el chunk "prompt" existe, si no {}.
- workflow: UI graph (dict) si el chunk "workflow" existe, si no {}.
- format_detected: chunks hallados unidos por "+" ("prompt",
"workflow" o "prompt+workflow").
Si el PNG no trae metadata de workflow, ok=False.
"""
try:
if png_path_or_url.startswith(("http://", "https://")):
with urllib.request.urlopen(png_path_or_url, timeout=timeout) as resp:
data = resp.read()
else:
with open(png_path_or_url, "rb") as f:
data = f.read()
except (urllib.error.URLError, OSError) as exc:
return {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "",
"error": f"no se pudo leer {png_path_or_url!r}: {exc}"}
try:
chunks = _png_text_chunks(data)
except ValueError as exc:
return {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "",
"error": str(exc)}
out = {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "", "error": ""}
found = []
if "prompt" in chunks:
try:
out["prompt"] = json.loads(chunks["prompt"])
found.append("prompt")
except json.JSONDecodeError:
pass
if "workflow" in chunks:
try:
out["workflow"] = json.loads(chunks["workflow"])
found.append("workflow")
except json.JSONDecodeError:
pass
out["format_detected"] = "+".join(found)
if found:
out["ok"] = True
else:
out["error"] = "el PNG no contiene metadata de workflow ComfyUI (chunks prompt/workflow)"
return out
def _png_text_chunks(data: bytes) -> dict:
"""Lee los chunks de texto (tEXt/zTXt/iTXt) de un PNG -> {keyword: texto}."""
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("no es un PNG valido (firma incorrecta)")
out = {}
off = 8
n = len(data)
while off + 8 <= n:
length = struct.unpack(">I", data[off:off + 4])[0]
ctype = data[off + 4:off + 8]
body = data[off + 8:off + 8 + length]
off += 12 + length # 4 len + 4 type + body + 4 crc
if ctype == b"tEXt":
kw, _, txt = body.partition(b"\x00")
out[kw.decode("latin1")] = txt.decode("latin1")
elif ctype == b"zTXt":
kw, _, rest = body.partition(b"\x00")
if rest:
comp_data = rest[1:] # rest[0] = metodo de compresion
try:
out[kw.decode("latin1")] = zlib.decompress(comp_data).decode("latin1")
except zlib.error:
pass
elif ctype == b"iTXt":
kw, _, rest = body.partition(b"\x00")
if len(rest) >= 2:
comp_flag = rest[0]
parts = rest[2:].split(b"\x00", 2) # lang\x00 translated\x00 text
if len(parts) == 3:
text_bytes = parts[2]
if comp_flag == 1:
try:
text_bytes = zlib.decompress(text_bytes)
except zlib.error:
text_bytes = b""
out[kw.decode("latin1")] = text_bytes.decode("utf-8", "replace")
elif ctype == b"IEND":
break
return out
if __name__ == "__main__":
import json as _json
import sys
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png"
res = comfyui_import_workflow_png(path)
print(_json.dumps({k: v for k, v in res.items() if k != "prompt"}, indent=2))
print("nodos en prompt:", len(res["prompt"]))
@@ -0,0 +1,73 @@
---
name: comfyui_inject_lora
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_inject_lora(workflow: dict, lora_name: str, *, strength_model: float = 1.0, strength_clip: float = 1.0, model_node: str | None = None, clip_node: str | None = None) -> dict"
description: "Inserta un nodo LoraLoader en un workflow ComfyUI ya construido (API format), reconectando las salidas model/clip de la fuente actual (CheckpointLoaderSimple o LoraLoader previo) hacia el LoRA y repuntando a los consumidores (KSampler, CLIPTextEncode). Llamar varias veces encadena LoRAs. Pura: no muta el dict de entrada (copia profunda)."
tags: [comfyui, ml, lora, stable-diffusion, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: workflow
desc: "dict en API format (ej. salida de comfyui_build_txt2img_workflow). No se muta; se devuelve una copia."
- name: lora_name
desc: "Nombre del archivo .safetensors del LoRA en models/loras/."
- name: strength_model
desc: "Fuerza del LoRA sobre el modelo (UNet). keyword-only."
- name: strength_clip
desc: "Fuerza del LoRA sobre el CLIP. keyword-only."
- name: model_node
desc: "node_id cuya salida MODEL (slot 0) alimentara el LoRA. Si None, se detecta la fuente que hoy alimenta KSampler.model (con el CheckpointLoaderSimple como fallback). keyword-only."
- name: clip_node
desc: "node_id cuya salida CLIP (slot 1) alimentara el LoRA. Si None, se detecta la fuente que hoy alimenta los CLIPTextEncode.clip. keyword-only."
output: "copia del workflow con un nodo LoraLoader insertado (node_id = max id numerico + 1) y reconectado entre la fuente model/clip y sus consumidores."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_inject_lora.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_lora import comfyui_inject_lora
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat, detailed")
wf = comfyui_inject_lora(base, "add_detail.safetensors", strength_model=0.8)
# El LoraLoader nuevo recibe model/clip del checkpoint ["4",0]/["4",1]
# y ahora KSampler.model == [lora_id, 0], CLIPTextEncode.clip == [lora_id, 1]
# Encadenar un segundo LoRA: el detector ve que ya pasa por el primero
wf = comfyui_inject_lora(wf, "anime_style.safetensors", strength_model=0.6)
# Cadena: checkpoint -> lora1 -> lora2 -> KSampler / CLIPTextEncode
```
## Cuando usarla
Cuando tengas un workflow txt2img/img2img construido y quieras aplicarle uno o
varios LoRAs sin reescribir el grafo. Llama una vez por LoRA: cada llamada inserta
el LoraLoader justo antes de los consumidores actuales, asi que encadenar es
idempotente respecto al orden de llamada. Para apilar muchos LoRAs, encadena.
## Gotchas
- Pura: no muta el `workflow` de entrada (trabaja sobre una copia profunda) y NO
valida que `lora_name` exista en el servidor. Valida con `comfyui_validate_workflow`.
- Asume la convencion de slots de ComfyUI: MODEL=output 0, CLIP=output 1, tanto
en CheckpointLoaderSimple como en LoraLoader. Workflows con loaders no estandar
pueden necesitar `model_node`/`clip_node` explicitos.
- Detecta la fuente actual por el KSampler.model y el primer CLIPTextEncode.clip.
Si el workflow no tiene un nodo cuyo class_type acabe en "KSampler", pasa
`model_node` explicito o lanza ValueError.
- El nuevo node_id es `max(ids numericos) + 1`. Si tu workflow usa ids no
numericos, el contador cae a `len(workflow) + 1`.
+130
View File
@@ -0,0 +1,130 @@
"""Inserta un nodo LoraLoader en un workflow ComfyUI ya construido (API format).
Reconecta las salidas model/clip de la fuente actual (el CheckpointLoaderSimple
o un LoraLoader previo) hacia el nuevo LoraLoader, y repunta a los consumidores
(KSampler, CLIPTextEncode) para que pasen por el LoRA. Llamar varias veces sobre
el mismo workflow encadena LoRAs.
Convencion de slots ComfyUI: tanto CheckpointLoaderSimple como LoraLoader
exponen MODEL en el output 0 y CLIP en el output 1.
Funcion pura: no muta el dict de entrada (trabaja sobre una copia profunda).
"""
import copy
def comfyui_inject_lora(
workflow: dict,
lora_name: str,
*,
strength_model: float = 1.0,
strength_clip: float = 1.0,
model_node: str | None = None,
clip_node: str | None = None,
) -> dict:
"""Devuelve una copia del workflow con un LoraLoader insertado y reconectado.
Args:
workflow: dict en API format (ej. salida de
comfyui_build_txt2img_workflow). No se muta.
lora_name: nombre del archivo .safetensors del LoRA en models/loras/.
strength_model: fuerza del LoRA sobre el modelo (UNet). keyword-only.
strength_clip: fuerza del LoRA sobre el CLIP. keyword-only.
model_node: node_id cuya salida MODEL (slot 0) alimentara el LoRA. Si
None, se detecta la fuente que hoy alimenta el KSampler.model (con el
CheckpointLoaderSimple como fallback). keyword-only.
clip_node: node_id cuya salida CLIP (slot 1) alimentara el LoRA. Si None,
se detecta la fuente que hoy alimenta los CLIPTextEncode.clip.
keyword-only.
Returns:
copia del workflow con el LoraLoader insertado. El nuevo node_id es el
maximo id numerico existente + 1.
Raises:
ValueError: si no se puede determinar la fuente model/clip y no se pasan
model_node/clip_node explicitos.
"""
wf = copy.deepcopy(workflow)
def _is_link(v) -> bool:
return (
isinstance(v, list)
and len(v) == 2
and isinstance(v[0], str)
and isinstance(v[1], int)
)
def _find_class(prefix):
for nid, node in wf.items():
if str(node.get("class_type", "")).startswith(prefix):
return nid
return None
ckpt = _find_class("CheckpointLoader")
# fuente actual de model/clip: la que alimenta KSampler.model y CLIPTextEncode.clip
model_src = None
clip_src = None
for node in wf.values():
ins = node.get("inputs", {})
if str(node.get("class_type", "")).endswith("KSampler") and _is_link(ins.get("model")):
model_src = list(ins["model"])
if node.get("class_type") == "CLIPTextEncode" and clip_src is None and _is_link(ins.get("clip")):
clip_src = list(ins["clip"])
if model_node is not None:
model_src = [model_node, 0]
elif model_src is None and ckpt is not None:
model_src = [ckpt, 0]
if clip_node is not None:
clip_src = [clip_node, 1]
elif clip_src is None and ckpt is not None:
clip_src = [ckpt, 1]
if model_src is None or clip_src is None:
raise ValueError(
"comfyui_inject_lora: no se pudo determinar la fuente model/clip; "
"pasa model_node y clip_node explicitos."
)
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
new_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
wf[new_id] = {
"class_type": "LoraLoader",
"inputs": {
"lora_name": lora_name,
"strength_model": strength_model,
"strength_clip": strength_clip,
"model": list(model_src),
"clip": list(clip_src),
},
}
# repuntar consumidores de model_src/clip_src hacia el LoraLoader (no el propio LoRA)
for nid, node in wf.items():
if nid == new_id:
continue
ins = node.get("inputs", {})
for k, v in list(ins.items()):
if _is_link(v) and list(v) == list(model_src):
ins[k] = [new_id, 0]
elif _is_link(v) and list(v) == list(clip_src):
ins[k] = [new_id, 1]
return wf
if __name__ == "__main__":
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat")
wf = comfyui_inject_lora(base, "add_detail.safetensors", strength_model=0.8)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,70 @@
---
name: comfyui_install_3d_model
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_install_3d_model(variant: str = \"mini\", *, hf_token: str | None = None, comfyui_dir: str = \"~/ComfyUI\") -> dict"
description: "Instala un checkpoint Hunyuan3D-2 (mini/standard/mv) en la carpeta checkpoints/ de ComfyUI para los nodos nativos imagen->3D (ImageOnlyCheckpointLoader). Cascada: si el destino ya existe reutiliza; si esta en la cache de HuggingFace copia desde ahi (sin red); si no, descarga con huggingface_hub (token de pass si gated). Resuelve la ruta real de checkpoints via extra_model_paths.yaml. Impura: YAML + disco + posible red + subprocess pass."
tags: [comfyui, ml, img-to-3d, hunyuan3d, model, install]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: variant
desc: "'mini' (≈5 GB VRAM, default), 'standard' (dit-v2-0, ≈6 GB) o 'mv' (multiview). Determina el repo de HF y el nombre destino del .safetensors."
- name: hf_token
desc: "Token de HuggingFace si la variante fuera gated. Si None y hace falta descargar, se intenta leer de 'pass show API_TOKEN_huggingFace'. keyword-only."
- name: comfyui_dir
desc: "Raiz de la instalacion de ComfyUI (se expande ~). La carpeta real de checkpoints se resuelve via extra_model_paths.yaml. keyword-only."
output: "dict {ok, path, bytes, reused_cache, error}. path = ruta del checkpoint en checkpoints/; reused_cache=True si ya estaba instalado o se copio de la cache de HF (sin descarga de red). Si falla, ok=False y error explica."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_install_3d_model.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_install_3d_model import comfyui_install_3d_model
res = comfyui_install_3d_model("mini")
# Si ya esta (cache o instalado): reused_cache=True, sin re-bajar 3.8 GB.
# res == {"ok": True, "path": "/mnt/2tb/comfyui_models/checkpoints/hunyuan3d-dit-v2-mini.safetensors",
# "bytes": 3819958234, "reused_cache": True, "error": ""}
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Antes de reconstruir mallas 3D con los nodos nativos de Hunyuan3D-2: asegura que
el checkpoint que pide `ImageOnlyCheckpointLoader` esta en `checkpoints/`. Llamala
una vez por PC/variante; en sucesivas devuelve `reused_cache=True` al instante. El
pipeline `comfyui_image_to_3d_oneshot` NO la llama (asume el modelo ya instalado);
ejecutala tu antes la primera vez.
## Gotchas
- Impura: lee YAML, escribe en disco (copia de GBs cuando toca), y puede hacer red
+ subprocess `pass`. La copia desde la cache de HF de un .safetensors de ~3.8 GB
tarda unos segundos; el caso `reused_cache` ya-instalado es instantaneo.
- Resuelve la carpeta de checkpoints real via `extra_model_paths.yaml` (en este
equipo `/mnt/2tb/comfyui_models/checkpoints/`, seccion `is_default`). Si el YAML
falta cae a `<comfyui_dir>/models/checkpoints`.
- La descarga (rama 3) necesita `huggingface_hub` en el venv. Si no esta instalado
y el modelo no esta en la cache, devuelve `ok=False` con instrucciones (instalar
huggingface_hub o usar `comfyui_download_model` con la URL de resolve de HF).
- Hunyuan3D-2 mini NO es gated (no requiere token). `standard`/`mv` se asumen
publicos tambien; si alguno fuera gated, pasa `hf_token` o ten el token en `pass`.
- Tras instalar, ComfyUI re-escanea `checkpoints/` dinamicamente (no hace falta
reiniciar el server para checkpoints; solo los custom nodes nuevos exigen restart).
- No valida el contenido del .safetensors mas alla de un tamano minimo; confia en
la integridad de la cache de HF o de la descarga de huggingface_hub.
@@ -0,0 +1,189 @@
"""Instala un checkpoint Hunyuan3D-2 en la carpeta checkpoints/ de ComfyUI.
ComfyUI 0.26.0 reconstruye mallas 3D con los nodos nativos de Hunyuan3D-2, que
cargan un checkpoint self-contained (DiT de forma + VAE 3D + encoder de imagen en
un solo .safetensors) via ImageOnlyCheckpointLoader. Esta funcion resuelve el repo
de HuggingFace de la variante pedida, REUTILIZA la cache de HF si ya esta bajado
(sin re-descargar), y copia el .safetensors a la carpeta checkpoints/ (la ruta real
que declara extra_model_paths.yaml) con el nombre que espera el loader nativo.
Cascada: (1) si el destino ya existe -> reutiliza; (2) si esta en la cache de HF
-> copia desde la cache; (3) si no -> descarga con huggingface_hub (token de
`pass` si la variante fuera gated).
Impura: lectura de YAML, escritura en disco, posible red (HTTP) y subprocess (pass).
"""
import os
import shutil
import subprocess
# variant -> (repo_id HF, ruta del archivo dentro del repo, nombre destino en checkpoints/)
_VARIANTS = {
"mini": (
"tencent/Hunyuan3D-2mini",
"hunyuan3d-dit-v2-mini/model.fp16.safetensors",
"hunyuan3d-dit-v2-mini.safetensors",
),
"standard": (
"tencent/Hunyuan3D-2",
"hunyuan3d-dit-v2-0/model.fp16.safetensors",
"hunyuan3d-dit-v2-0.safetensors",
),
"mv": (
"tencent/Hunyuan3D-2mv",
"hunyuan3d-dit-v2-mv/model.fp16.safetensors",
"hunyuan3d-dit-v2-mv.safetensors",
),
}
_MIN_BYTES = 1_000_000 # un .safetensors real pesa GBs; descarta restos/HTML.
def _checkpoints_dir(comfyui_dir: str) -> str:
"""Resuelve el directorio real de checkpoints de ComfyUI.
Lee extra_model_paths.yaml (prefiere la seccion con is_default) para devolver
`<base_path>/<checkpoints_subdir>`. Si el YAML no existe o no se puede parsear,
cae a la ruta nativa `<comfyui_dir>/models/checkpoints`.
"""
base = os.path.expanduser(comfyui_dir)
native = os.path.join(base, "models", "checkpoints")
yml = os.path.join(base, "extra_model_paths.yaml")
if not os.path.isfile(yml):
return native
try:
import yaml
with open(yml, encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
except Exception: # noqa: BLE001 — YAML/PyYAML no disponible: usar nativa.
return native
if not isinstance(data, dict):
return native
fallback = None
for section in data.values():
if not isinstance(section, dict):
continue
sub = section.get("checkpoints")
if not sub:
continue
bp = os.path.expanduser(str(section.get("base_path", "")))
first_line = str(sub).splitlines()[0].strip()
resolved = os.path.join(bp, first_line)
if section.get("is_default"):
return resolved
if fallback is None:
fallback = resolved
return fallback or native
def _find_in_hf_cache(repo_id: str, repo_filename: str) -> str | None:
"""Busca el archivo en la cache local de HuggingFace, sin red.
Layout: ~/.cache/huggingface/hub/models--<org>--<name>/snapshots/<hash>/...
Resuelve el symlink al blob real y verifica un tamano minimo. Devuelve la ruta
real o None.
"""
org_name = repo_id.replace("/", "--")
hub = os.path.expanduser("~/.cache/huggingface/hub")
cache_root = os.path.join(hub, f"models--{org_name}", "snapshots")
if not os.path.isdir(cache_root):
return None
target = os.path.basename(repo_filename)
for snap in os.listdir(cache_root):
snap_dir = os.path.join(cache_root, snap)
if not os.path.isdir(snap_dir):
continue
for root, _dirs, files in os.walk(snap_dir):
if target in files:
real = os.path.realpath(os.path.join(root, target))
if os.path.isfile(real) and os.path.getsize(real) >= _MIN_BYTES:
return real
return None
def _pass_hf_token() -> str | None:
"""Lee el token de HuggingFace de `pass API_TOKEN_huggingFace`, o None."""
try:
out = subprocess.run(
["pass", "show", "API_TOKEN_huggingFace"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0:
tok = out.stdout.splitlines()[0].strip() if out.stdout.strip() else ""
return tok or None
except (OSError, subprocess.SubprocessError):
pass
return None
def comfyui_install_3d_model(
variant: str = "mini",
*,
hf_token: str | None = None,
comfyui_dir: str = "~/ComfyUI",
) -> dict:
"""Instala el checkpoint Hunyuan3D-2 de la variante pedida en checkpoints/.
Args:
variant: "mini" (≈5 GB VRAM, default), "standard" (dit-v2-0, ≈6 GB) o
"mv" (multiview). Determina el repo de HF y el nombre destino.
hf_token: token de HuggingFace si la variante fuera gated. Si None y hace
falta descargar, se intenta leer de `pass show API_TOKEN_huggingFace`.
keyword-only.
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~). La carpeta
real de checkpoints se resuelve via extra_model_paths.yaml. keyword-only.
Returns:
dict {ok, path, bytes, reused_cache, error}. path = ruta del checkpoint en
checkpoints/; reused_cache=True si ya estaba instalado o se copio de la
cache de HF (sin descarga de red). Si falla, ok=False y error explica.
"""
if variant not in _VARIANTS:
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
"error": f"variant {variant!r} no valida; usa {sorted(_VARIANTS)}"}
repo_id, repo_filename, dest_name = _VARIANTS[variant]
ckpt_dir = _checkpoints_dir(comfyui_dir)
dest = os.path.join(ckpt_dir, dest_name)
# 1. Ya instalado.
if os.path.isfile(dest) and os.path.getsize(dest) >= _MIN_BYTES:
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
"reused_cache": True, "error": ""}
# 2. En la cache de HF -> copiar (sin red).
cached = _find_in_hf_cache(repo_id, repo_filename)
if cached:
try:
os.makedirs(ckpt_dir, exist_ok=True)
shutil.copy2(cached, dest)
except OSError as exc:
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
"error": f"no se pudo copiar de la cache HF a {dest}: {exc}"}
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
"reused_cache": True, "error": ""}
# 3. Descargar con huggingface_hub (lazy; usa su propia cache).
token = hf_token or _pass_hf_token()
try:
from huggingface_hub import hf_hub_download
except ImportError:
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
"error": ("no esta en la cache de HF y huggingface_hub no esta "
"instalado en este venv. Instala huggingface_hub o baja "
f"el archivo {repo_filename!r} de {repo_id!r} a mano (o con "
"comfyui_download_model usando la URL de resolve de HF).")}
try:
local = hf_hub_download(repo_id=repo_id, filename=repo_filename, token=token)
os.makedirs(ckpt_dir, exist_ok=True)
shutil.copy2(local, dest)
except Exception as exc: # noqa: BLE001 — red/auth/gated/escritura.
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
"error": f"fallo descargando {repo_filename} de {repo_id}: {exc}"}
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
"reused_cache": False, "error": ""}
if __name__ == "__main__":
import json
print(json.dumps(comfyui_install_3d_model("mini"), ensure_ascii=False, indent=2))
@@ -0,0 +1,72 @@
---
name: comfyui_install_custom_node
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_install_custom_node(repo_url: str, *, comfyui_dir: str = \"~/ComfyUI\", pip_install: bool = True, restart: bool = False) -> dict"
description: "Instala un custom node de ComfyUI: git clone del repo en custom_nodes/<name> + (si trae requirements.txt) pip install de sus deps en el venv de ComfyUI. El venv suele crearse con uv y no trae pip, asi que el instalador se autodetecta (python -m pip -> uv pip -> pip). NO reinicia el servidor por defecto (restart=False): el nodo se carga al siguiente arranque. Impura: subprocess git/pip/uv + escritura en disco. Solo stdlib."
tags: [comfyui, ml, custom-nodes, install, git, pip, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "shutil", "subprocess"]
params:
- name: repo_url
desc: "URL del repositorio git del custom node (ej. 'https://github.com/rgthree/rgthree-comfy')."
- name: comfyui_dir
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'. keyword-only."
- name: pip_install
desc: "Si True y el repo trae requirements.txt, instala sus dependencias en el venv de ComfyUI. keyword-only."
- name: restart
desc: "NO soportado de forma segura (default False). El nodo se carga al reiniciar el servidor; hazlo tu cuando no haya generaciones en curso. True solo se anota en error, NO reinicia (evita cortar trabajo del servidor). keyword-only."
output: "dict {ok, path, pip_done, error}. ok=True si el nodo quedo clonado en disco (o ya estaba). pip_done=True si se instalaron las dependencias. error describe el fallo de git/pip o las advertencias (ya existia, sin requirements, restart ignorado)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_install_custom_node.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_install_custom_node import comfyui_install_custom_node
out = comfyui_install_custom_node(
"https://github.com/rgthree/rgthree-comfy",
restart=False, # no reinicia el server; el nodo se carga al proximo arranque
)
print(out["ok"], out["path"], "pip_done=", out["pip_done"])
# {"ok": True, "path": ".../custom_nodes/rgthree-comfy", "pip_done": True, "error": ""}
```
El `./fn run` directo no aplica (firma con `*` keyword-only); usa el import o un
heredoc.
## Cuando usarla
Cuando un workflow ajeno usa un nodo custom que no tienes
(`comfyui_resolve_workflow_deps` te dice cual falta) o quieras anadir un pack de
nodos conocido. Tras instalar, reinicia ComfyUI manualmente (cuando no haya
generaciones en curso) para que el nodo aparezca.
## Gotchas
- Impura: ejecuta `git clone` y, si hay requirements.txt, `pip`/`uv pip` en el
venv de ComfyUI; escribe en `~/ComfyUI/custom_nodes/`.
- **NO reinicia el servidor**. `restart=True` se ignora (solo se anota en `error`):
un restart en caliente corta cualquier generacion en curso. Reinicia tu cuando
el server este libre. El nodo NO se carga hasta ese reinicio.
- El venv de ComfyUI creado con uv no trae `pip`: la funcion detecta el instalador
(`python -m pip` -> `uv pip --python <venv>` -> binario `pip`). Si no hay ninguno,
`pip_done=False` y lo anota en `error` (el clone sigue siendo valido).
- Idempotente con el clone: si el dir ya existe NO re-clona (lo anota en `error`),
pero SI reintenta el pip install si `pip_install=True`.
- `ok=True` significa "clonado en disco", no "cargado en el server". Un clone OK
con pip fallido devuelve `ok=True, pip_done=False` + el error de pip.
- Un repo_url invalido (404) devuelve `ok=False` con el stderr de git.
@@ -0,0 +1,137 @@
"""Instala un custom node de ComfyUI: git clone + pip install de sus deps.
Clona el repo en `<comfyui_dir>/custom_nodes/<name>` y, si trae
`requirements.txt`, instala sus dependencias en el venv de ComfyUI
(`<comfyui_dir>/.venv`). El venv de ComfyUI suele crearse con uv y no trae pip;
por eso el instalador se autodetecta en orden: `python -m pip`, luego
`uv pip --python <venv>`, luego el binario `pip` del venv. NO reinicia el
servidor por defecto (restart=False): el nodo no se carga hasta el siguiente
arranque de ComfyUI, asi que reiniciar es una decision explicita del caller (un
restart en caliente corta cualquier generacion en curso).
Impura: ejecuta subprocess (git, pip/uv) y escribe en disco. Solo stdlib.
"""
import os
import shutil
import subprocess
_GIT_TIMEOUT = 300.0
_PIP_TIMEOUT = 600.0
def _pip_install_cmd(base: str, req: str):
"""Comando para instalar requirements en el venv de ComfyUI, o None.
Prueba en orden: `python -m pip` (si el venv tiene pip), `uv pip` apuntando
al python del venv (venvs uv sin pip), y por ultimo el binario `pip` del
venv. Devuelve la lista de args lista para subprocess o None si no hay
instalador disponible.
"""
venv_py = os.path.join(base, ".venv", "bin", "python")
if os.path.isfile(venv_py):
probe = subprocess.run(
[venv_py, "-m", "pip", "--version"],
capture_output=True, text=True,
)
if probe.returncode == 0:
return [venv_py, "-m", "pip", "install", "-r", req]
if shutil.which("uv"):
return ["uv", "pip", "install", "-r", req, "--python", venv_py]
for cand in ("pip", "pip3"):
pip_bin = os.path.join(base, ".venv", "bin", cand)
if os.path.isfile(pip_bin):
return [pip_bin, "install", "-r", req]
return None
def comfyui_install_custom_node(
repo_url: str,
*,
comfyui_dir: str = "~/ComfyUI",
pip_install: bool = True,
restart: bool = False,
) -> dict:
"""Clona un custom node y (opcional) instala sus requirements.
Args:
repo_url: URL del repositorio git del custom node
(ej. "https://github.com/rgthree/rgthree-comfy").
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
keyword-only.
pip_install: si True y el repo trae requirements.txt, instala sus
dependencias en el venv de ComfyUI. keyword-only.
restart: NO soportado de forma segura desde aqui (por defecto False).
El nodo se carga al reiniciar el servidor; hazlo tu cuando no haya
generaciones en curso. Si se pasa True se anota en el error pero NO
se reinicia (evita cortar trabajo del servidor). keyword-only.
Returns:
dict {ok, path, pip_done, error}. ok=True si el nodo quedo clonado en
disco (o ya estaba). pip_done=True si se instalaron las dependencias.
error describe el fallo de git/pip o la advertencia de restart.
"""
base = os.path.expanduser(comfyui_dir)
custom_dir = os.path.join(base, "custom_nodes")
name = os.path.basename(repo_url.rstrip("/"))
if name.endswith(".git"):
name = name[:-4]
if not name:
return {"ok": False, "path": "", "pip_done": False,
"error": f"repo_url invalido: {repo_url!r}"}
dest = os.path.join(custom_dir, name)
already = os.path.isdir(dest)
if not already:
os.makedirs(custom_dir, exist_ok=True)
try:
proc = subprocess.run(
["git", "clone", "--depth", "1", repo_url, dest],
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError) as exc:
return {"ok": False, "path": "", "pip_done": False,
"error": f"git clone fallo: {exc}"}
if proc.returncode != 0:
return {"ok": False, "path": "", "pip_done": False,
"error": f"git clone fallo ({proc.returncode}): {proc.stderr.strip()[:300]}"}
notes = []
if already:
notes.append(f"ya existia en {dest} (no se re-clono)")
pip_done = False
if pip_install:
req = os.path.join(dest, "requirements.txt")
if os.path.isfile(req):
cmd = _pip_install_cmd(base, req)
if cmd is None:
notes.append(f"no se encontro instalador (pip/uv) para {base}/.venv (deps omitidas)")
else:
try:
pproc = subprocess.run(
cmd, capture_output=True, text=True, timeout=_PIP_TIMEOUT,
)
pip_done = pproc.returncode == 0
if not pip_done:
notes.append(f"pip install fallo: {pproc.stderr.strip()[:300]}")
except (subprocess.TimeoutExpired, OSError) as exc:
notes.append(f"pip install fallo: {exc}")
else:
notes.append("sin requirements.txt (nada que instalar)")
if restart:
notes.append(
"restart=True ignorado: reinicia ComfyUI manualmente cuando no haya "
"generaciones en curso para cargar el nodo"
)
return {"ok": True, "path": dest, "pip_done": pip_done, "error": "; ".join(notes)}
if __name__ == "__main__":
import json
out = comfyui_install_custom_node(
"https://github.com/rgthree/rgthree-comfy", restart=False,
)
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,69 @@
---
name: comfyui_list_installed_models
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_list_installed_models(folder: str | None = None, comfyui_dir: str = \"~/ComfyUI\") -> dict"
description: "Lista los modelos instalados de ComfyUI por carpeta de tipo (checkpoints, loras, vae, controlnet, upscale_models), resolviendo las rutas REALES: escanea tanto la nativa <comfyui_dir>/models/<folder>/ como las externas declaradas en extra_model_paths.yaml (en este equipo /mnt/2tb/comfyui_models/). Escaneo de FS (no depende del servidor). Impura: lectura de disco + parse de YAML. Solo stdlib + PyYAML."
tags: [comfyui, ml, models, inventory, filesystem, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "yaml"]
params:
- name: folder
desc: "Carpeta concreta a listar (ej. 'checkpoints'). Si None, lista todas (checkpoints, loras, vae, controlnet, upscale_models)."
- name: comfyui_dir
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'."
output: "dict {ok, models, error}. models = {folder: [nombre, ...]} con los archivos de modelo (dedup por nombre) hallados en la ruta nativa models/<folder>/ y en las externas de extra_model_paths.yaml. ok=True salvo fallo de escaneo."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_list_installed_models.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_list_installed_models import comfyui_list_installed_models
out = comfyui_list_installed_models()
print(out["models"]["checkpoints"])
# ['dreamshaper_8.safetensors', 'juggernaut_xl_v11.safetensors', 'v1-5-pruned-emaonly-fp16.safetensors', ...]
# -> resueltos desde /mnt/2tb/comfyui_models/checkpoints/ via extra_model_paths.yaml
# Una sola carpeta:
loras = comfyui_list_installed_models(folder="loras")["models"]["loras"]
```
El bloque se lanza con el python del venv (`python/.venv/bin/python3`).
## Cuando usarla
Cuando necesites saber que checkpoints/LoRAs/VAEs/ControlNets/upscalers tienes ya
descargados antes de construir un workflow (los builders necesitan el nombre exacto
del modelo) o antes de descargar uno nuevo. Ve los modelos esten en `models/` o en
el disco externo de `extra_model_paths.yaml`.
## Gotchas
- Impura: lee disco y parsea `extra_model_paths.yaml`. NO consulta el servidor de
ComfyUI, asi que funciona aunque el server este reiniciandose.
- **Resuelve la ruta REAL**: en este equipo los modelos viven en
`/mnt/2tb/comfyui_models/` (no en `~/ComfyUI/models/`), declarado en
`extra_model_paths.yaml`. La funcion lee ese YAML (incluida la sintaxis de
carpeta multilinea) y suma esas rutas a la nativa, dedup por nombre.
- Si `extra_model_paths.yaml` no existe o PyYAML no lo puede parsear, degrada a
solo las rutas nativas `~/ComfyUI/models/<folder>/` (no falla).
- Lista por nombre de archivo (no rutas completas) y solo extensiones de modelo
(.safetensors, .ckpt, .pt, .pth, .bin, .gguf, .sft, .onnx). Subcarpetas dentro de
cada folder NO se recorren (solo el primer nivel).
- El catalogo que ve el SERVIDOR (combos de la UI) puede diferir si el server no se
ha refrescado tras una descarga; para el combo en vivo usa `comfyui_object_info`
o `comfyui_refresh_nodes_ui`.
@@ -0,0 +1,105 @@
"""Lista los modelos instalados de ComfyUI por carpeta, resolviendo rutas reales.
ComfyUI puede leer los modelos desde rutas externas declaradas en
`<comfyui_dir>/extra_model_paths.yaml` (en este equipo, /mnt/2tb/comfyui_models/),
ademas de las nativas `<comfyui_dir>/models/<folder>/`. Esta funcion escanea
AMBAS para cada carpeta de tipo (checkpoints, loras, vae, controlnet,
upscale_models), de modo que ve los modelos aunque no esten bajo `models/`.
El escaneo es del sistema de archivos (no depende del servidor ComfyUI), asi que
funciona aunque el servidor este reiniciandose.
Impura: lectura de disco (FS scan + parse de YAML). Solo stdlib + PyYAML.
"""
import os
_DEFAULT_FOLDERS = ["checkpoints", "loras", "vae", "controlnet", "upscale_models"]
_MODEL_EXTS = (
".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx",
)
def _resolve_external_roots(base: str) -> dict:
"""Lee extra_model_paths.yaml y devuelve {folder: [dir_externo, ...]}.
Maneja valores de carpeta multilinea (varias subrutas por clave). Si el YAML
no existe o no se puede parsear, devuelve {} (solo se usaran las rutas
nativas).
"""
roots: dict = {}
yml = os.path.join(base, "extra_model_paths.yaml")
if not os.path.isfile(yml):
return roots
try:
import yaml
with open(yml, encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
except Exception: # noqa: BLE001 — YAML ilegible: degradar a rutas nativas
return roots
if not isinstance(data, dict):
return roots
for section in data.values():
if not isinstance(section, dict):
continue
bp = os.path.expanduser(str(section.get("base_path", "")))
for key in _DEFAULT_FOLDERS:
sub = section.get(key)
if not sub:
continue
for line in str(sub).splitlines():
line = line.strip()
if not line:
continue
roots.setdefault(key, []).append(os.path.join(bp, line))
return roots
def comfyui_list_installed_models(
folder: str | None = None,
comfyui_dir: str = "~/ComfyUI",
) -> dict:
"""Lista los modelos en disco por carpeta de tipo.
Args:
folder: carpeta concreta a listar (ej. "checkpoints"). Si None, lista
todas las de _DEFAULT_FOLDERS.
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
Returns:
dict {ok, models, error}. models es {folder: [nombre, ...]} con los
archivos de modelo (dedup por nombre) hallados tanto en la ruta nativa
`models/<folder>/` como en las externas de extra_model_paths.yaml. ok es
True salvo fallo inesperado.
"""
base = os.path.expanduser(comfyui_dir)
folders = [folder] if folder else list(_DEFAULT_FOLDERS)
external = _resolve_external_roots(base)
models: dict = {}
try:
for f in folders:
dirs = [os.path.join(base, "models", f)] + external.get(f, [])
names: list = []
seen: set = set()
for d in dirs:
if not os.path.isdir(d):
continue
for entry in sorted(os.listdir(d)):
if entry in seen:
continue
p = os.path.join(d, entry)
if os.path.isfile(p) and entry.lower().endswith(_MODEL_EXTS):
seen.add(entry)
names.append(entry)
models[f] = names
except OSError as exc:
return {"ok": False, "models": models, "error": f"fallo escaneando: {exc}"}
return {"ok": True, "models": models, "error": ""}
if __name__ == "__main__":
import json
print(json.dumps(comfyui_list_installed_models(), ensure_ascii=False, indent=2))
@@ -0,0 +1,66 @@
---
name: comfyui_object_info
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_object_info(server: str = \"127.0.0.1:8188\", node_class: str | None = None, timeout: float = 30.0) -> dict"
description: "Consulta el catalogo de nodos de un servidor ComfyUI via GET /object_info (o un nodo concreto con /object_info/{node_class}). Devuelve specs de inputs y valores enumerados (ej. lista de checkpoints visibles). Impura: HTTP GET, solo stdlib."
tags: [comfyui, ml, image-generation, stable-diffusion, introspection, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: node_class
desc: "Si se pasa, consulta solo ese class_type via /object_info/{node_class} (ej. 'CheckpointLoaderSimple'). None devuelve el catalogo completo."
- name: timeout
desc: "Timeout de la peticion HTTP en segundos."
output: "dict del catalogo. Con node_class=None es {class_type: spec, ...} (cientos de nodos). Con node_class set, {class_type: spec} de un solo item. Cada spec tiene input.required/optional con tipos y enums; ej. info['CheckpointLoaderSimple']['input']['required']['ckpt_name'][0] es la lista de checkpoints."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_object_info.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_object_info import comfyui_object_info
info = comfyui_object_info() # catalogo completo
print(len(info)) # ~792 nodos
ckpts = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
print(ckpts) # ['v1-5-pruned-emaonly-fp16.safetensors']
ks = comfyui_object_info(node_class="KSampler") # solo un nodo
print(list(ks.keys())) # ['KSampler']
```
O lanzable directo con: `./fn run comfyui_object_info` (imprime n nodos + checkpoints visibles).
## Cuando usarla
Antes de construir o enviar un workflow: descubre que checkpoints, samplers,
schedulers y nodos existen en el servidor concreto. Usala para validar que el
`ckpt_name` que vas a poner en `comfyui_build_txt2img_workflow` existe, o para
explorar nodos disponibles (LoRA loaders, upscalers, ControlNet) antes de
componer workflows mas ricos.
## Gotchas
- El catalogo completo es grande (cientos de nodos): preferir `node_class` si
solo necesitas uno.
- Los valores enumerados (checkpoints, vaes, loras) reflejan lo que el SERVIDOR
ve en sus carpetas models/, no lo que hay en tu disco local. Si acabas de
copiar un checkpoint, el servidor puede no haberlo escaneado hasta reiniciar o
refrescar.
- Lanza RuntimeError si ComfyUI no esta arriba (conexion rechazada) o responde
con error. El catalogo solo esta disponible con el servidor corriendo.
@@ -0,0 +1,65 @@
"""Consulta el catalogo de nodos de un servidor ComfyUI via GET /object_info.
Funcion impura: hace red (HTTP GET). Solo stdlib (urllib, json).
El catalogo describe cada class_type disponible: sus inputs requeridos y
opcionales, sus tipos, y los valores enumerados (ej. la lista de checkpoints
visibles para el servidor en CheckpointLoaderSimple). Util para validar un
workflow antes de enviarlo y para descubrir que checkpoints/samplers existen.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
def comfyui_object_info(
server: str = "127.0.0.1:8188",
node_class: str | None = None,
timeout: float = 30.0,
) -> dict:
"""Recupera el catalogo de nodos (o uno concreto) de ComfyUI.
Args:
server: host:port del servidor ComfyUI (sin esquema).
node_class: si se pasa, consulta solo ese class_type via
GET /object_info/{node_class} (ej. "CheckpointLoaderSimple").
Si es None, devuelve el catalogo completo (GET /object_info).
timeout: timeout de la peticion HTTP en segundos.
Returns:
dict con el catalogo. Con node_class=None es {class_type: spec, ...}.
Con node_class set, ComfyUI devuelve {class_type: spec} (un solo item).
Raises:
RuntimeError: si la peticion HTTP falla (conexion rechazada, timeout,
HTTP de error) o si la respuesta no es JSON valido. El mensaje
incluye el cuerpo del error cuando ComfyUI lo provee.
"""
path = "/object_info"
if node_class is not None:
path = f"/object_info/{urllib.parse.quote(node_class)}"
url = f"http://{server}{path}"
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
raise RuntimeError(
f"comfyui_object_info: HTTP {exc.code} en {url}: {body}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"comfyui_object_info: no se pudo conectar a {url}: {exc.reason}"
) from exc
except json.JSONDecodeError as exc:
raise RuntimeError(
f"comfyui_object_info: respuesta no es JSON valido desde {url}: {exc}"
) from exc
if __name__ == "__main__":
info = comfyui_object_info()
print(f"nodos disponibles: {len(info)}")
ckpts = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
print(f"checkpoints visibles: {ckpts}")
@@ -0,0 +1,61 @@
---
name: comfyui_read_png_metadata
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_read_png_metadata(png_path: str) -> dict"
description: "Lee los parametros de generacion de un PNG generado por ComfyUI: extrae el chunk 'prompt' (API format) y resume modelo, seed, steps, cfg, sampler, scheduler, denoise y los prompts positivo/negativo siguiendo las conexiones del KSampler. Comparte el lector de chunks PNG con comfyui_import_workflow_png. Impura: lectura de disco, solo stdlib."
tags: [comfyui, ml, png, metadata, workflow, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: png_path
desc: "Ruta local del PNG generado por ComfyUI."
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_read_png_metadata import comfyui_read_png_metadata
res = comfyui_read_png_metadata(os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"))
# res["ok"] == True
# res["parameters"]["seed"] # ej. 20260623
# res["parameters"]["model"] # ej. "dreamshaper_8.safetensors"
# res["parameters"]["positive"] # el prompt positivo usado
```
O lanzable directo con: `./fn run comfyui_read_png_metadata <ruta.png>`.
## Cuando usarla
Cuando quieras saber con que parametros se genero una imagen (que seed, modelo o
prompt) sin abrir el grafo entero: para reproducir una imagen que te gusto, para
catalogar outputs, o para comparar generaciones. Si necesitas el workflow completo
para relanzarlo usa `comfyui_import_workflow_png` (devuelve el dict entero).
## Gotchas
- Impura: lee el archivo del disco. Un path inexistente o un PNG sin chunk
'prompt' devuelve `{ok: False, error: ...}` (no lanza).
- `parameters` se extrae del primer nodo cuyo class_type acaba en "KSampler" y de
los CLIPTextEncode conectados a sus inputs positive/negative. Workflows muy
custom (varios samplers, sin CheckpointLoaderSimple) pueden dar `parameters`
parcial; el `prompt` completo siempre se devuelve para inspeccion manual.
- Lee chunks tEXt/zTXt/iTXt; los PNG de la API REST solo traen 'prompt' (no
'workflow'), suficiente para los parametros.
- Marcada impura (no pura) porque hace I/O de disco, segun la regla de pureza del
registry; la logica de parseo en si es determinista.
@@ -0,0 +1,125 @@
"""Lee los parametros de generacion de un PNG generado por ComfyUI.
Extrae el chunk "prompt" (API format) de los chunks de texto del PNG y resume
los parametros de generacion: modelo, seed, steps, cfg, sampler, scheduler,
denoise y los prompts positivo/negativo (siguiendo las conexiones del KSampler).
Impura: lectura de disco. Solo stdlib (struct, zlib, json).
"""
import json
import struct
import zlib
def comfyui_read_png_metadata(png_path: str) -> dict:
"""Devuelve {ok, prompt, parameters, error} de un PNG de ComfyUI.
Args:
png_path: ruta del PNG generado por ComfyUI.
Returns:
dict con:
- ok: bool.
- prompt: el workflow API format embebido (dict), o {}.
- parameters: resumen {model, seed, steps, cfg, sampler_name,
scheduler, denoise, positive, negative} extraido del KSampler y los
nodos conectados, o {}.
- error: mensaje si algo fallo.
"""
try:
with open(png_path, "rb") as f:
data = f.read()
except OSError as exc:
return {"ok": False, "prompt": {}, "parameters": {},
"error": f"no se pudo leer {png_path!r}: {exc}"}
try:
chunks = _png_text_chunks(data)
except ValueError as exc:
return {"ok": False, "prompt": {}, "parameters": {}, "error": str(exc)}
if "prompt" not in chunks:
return {"ok": False, "prompt": {}, "parameters": {},
"error": "el PNG no contiene chunk 'prompt' de ComfyUI"}
try:
prompt = json.loads(chunks["prompt"])
except json.JSONDecodeError as exc:
return {"ok": False, "prompt": {}, "parameters": {},
"error": f"chunk 'prompt' no es JSON valido: {exc}"}
return {"ok": True, "prompt": prompt, "parameters": _extract_params(prompt), "error": ""}
def _extract_params(prompt: dict) -> dict:
params = {}
ksampler = None
for node in prompt.values():
if isinstance(node, dict) and str(node.get("class_type", "")).endswith("KSampler"):
ksampler = node
break
if ksampler:
ins = ksampler.get("inputs", {})
for k in ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"):
if k in ins and not isinstance(ins[k], list):
params[k] = ins[k]
for slot in ("positive", "negative"):
link = ins.get(slot)
if isinstance(link, list) and len(link) == 2:
tnode = prompt.get(str(link[0]), {})
txt = tnode.get("inputs", {}).get("text")
if isinstance(txt, str):
params[slot] = txt
for node in prompt.values():
if isinstance(node, dict) and str(node.get("class_type", "")).startswith("CheckpointLoader"):
ck = node.get("inputs", {}).get("ckpt_name")
if ck:
params["model"] = ck
break
return params
def _png_text_chunks(data: bytes) -> dict:
"""Lee los chunks de texto (tEXt/zTXt/iTXt) de un PNG -> {keyword: texto}."""
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("no es un PNG valido (firma incorrecta)")
out = {}
off = 8
n = len(data)
while off + 8 <= n:
length = struct.unpack(">I", data[off:off + 4])[0]
ctype = data[off + 4:off + 8]
body = data[off + 8:off + 8 + length]
off += 12 + length
if ctype == b"tEXt":
kw, _, txt = body.partition(b"\x00")
out[kw.decode("latin1")] = txt.decode("latin1")
elif ctype == b"zTXt":
kw, _, rest = body.partition(b"\x00")
if rest:
try:
out[kw.decode("latin1")] = zlib.decompress(rest[1:]).decode("latin1")
except zlib.error:
pass
elif ctype == b"iTXt":
kw, _, rest = body.partition(b"\x00")
if len(rest) >= 2:
comp_flag = rest[0]
parts = rest[2:].split(b"\x00", 2)
if len(parts) == 3:
text_bytes = parts[2]
if comp_flag == 1:
try:
text_bytes = zlib.decompress(text_bytes)
except zlib.error:
text_bytes = b""
out[kw.decode("latin1")] = text_bytes.decode("utf-8", "replace")
elif ctype == b"IEND":
break
return out
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png"
res = comfyui_read_png_metadata(path)
print(json.dumps({"ok": res["ok"], "parameters": res["parameters"], "error": res["error"]}, indent=2))
@@ -0,0 +1,73 @@
---
name: comfyui_resolve_workflow_deps
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_resolve_workflow_deps(workflow: dict, server: str = \"127.0.0.1:8188\") -> dict"
description: "Resuelve las dependencias de un workflow ComfyUI ajeno: lo valida contra /object_info y traduce lo que falta en acciones concretas: nodos custom ausentes -> repos a instalar (comfyui_install_custom_node) y modelos ausentes -> assets a descargar (comfyui_search_civitai_models + comfyui_download_model). Compone comfyui_validate_workflow. Impura: HTTP GET. Solo stdlib."
tags: [comfyui, ml, workflow, dependencies, validation, stable-diffusion]
uses_functions: [comfyui_validate_workflow_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "sys"]
params:
- name: workflow
desc: "dict en API format ({node_id: {class_type, inputs}}), normalmente importado de internet con comfyui_import_workflow_json/_png."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_resolve_workflow_deps import comfyui_resolve_workflow_deps
# Workflow con un nodo custom y un modelo que no estan en este server:
wf = {
"1": {"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "no_existe_xyz.safetensors"}},
"2": {"class_type": "NodoQueNoExiste_XYZ", "inputs": {}},
}
res = comfyui_resolve_workflow_deps(wf) # server 127.0.0.1:8188 vivo
# res["missing_nodes"] == ["NodoQueNoExiste_XYZ"]
# res["missing_models"] == [{"node": "1", "input": "ckpt_name", "value": "no_existe_xyz.safetensors"}]
# res["suggestions"] -> 2 acciones: {kind:"node", action:"install_custom_node", ...}
# {kind:"model", action:"search_and_download", ...}
```
El bloque se lanza con el python del venv y necesita el server vivo en
`127.0.0.1:8188`.
## Cuando usarla
Justo despues de importar un workflow de internet
(`comfyui_import_workflow_json`/`_png`) y antes de intentar ejecutarlo: te dice
exactamente que nodos custom instalar y que modelos descargar para que funcione,
con la funcion concreta a usar en cada caso. Evita el ciclo de prueba y error de
encolar workflows ajenos.
## Gotchas
- Impura: compone `comfyui_validate_workflow`, que hace HTTP GET a `/object_info`.
Requiere el servidor vivo; si esta caido o reiniciandose devuelve
`{ok: False, error: ...}` (no lanza). Reintenta tu con backoff.
- Las `suggestions` son guias accionables (hint + funcion), NO resuelven el repo
exacto de cada nodo automaticamente: identificar el repo de un nodo custom por
su class_type requiere la DB de ComfyUI-Manager o una busqueda manual en GitHub.
- `missing_models` solo cubre inputs de modelo conocidos (ckpt_name, lora_name,
vae_name, control_net_name, ...) cuyo valor sea un string fuera del combo. No
valida rangos numericos ni tipos de conexion (hereda los limites de
`comfyui_validate_workflow`).
- Un workflow ya completo devuelve `missing_nodes=[]`, `missing_models=[]`,
`suggestions=[]` con `ok=True`: listo para encolar.
@@ -0,0 +1,102 @@
"""Resuelve las dependencias de un workflow ComfyUI ajeno antes de ejecutarlo.
Dado un workflow (p.ej. importado de internet con comfyui_import_workflow_json /
_png), lo valida contra el servidor y traduce lo que falta en acciones
concretas: nodos custom ausentes -> repos a instalar (comfyui_install_custom_node)
y modelos ausentes -> assets a descargar (comfyui_search_civitai_models +
comfyui_download_model). Asi un workflow desconocido se vuelve ejecutable sin
prueba y error.
Compone comfyui_validate_workflow (no reimplementa la consulta a /object_info).
Impura: red (HTTP GET via validate_workflow). Solo stdlib.
"""
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_validate_workflow import comfyui_validate_workflow # noqa: E402
def comfyui_resolve_workflow_deps(
workflow: dict,
server: str = "127.0.0.1:8188",
) -> dict:
"""Lista nodos y modelos faltantes de un workflow y sugiere como obtenerlos.
Args:
workflow: dict en API format ({node_id: {class_type, inputs}}).
server: host:port del servidor ComfyUI (sin esquema). Debe estar vivo
para consultar /object_info.
Returns:
dict {ok, missing_nodes, missing_models, suggestions, error}:
- ok: la validacion se pudo ejecutar (el servidor respondio).
- missing_nodes: class_type ausentes en el servidor (nodos custom).
- missing_models: lista de {node, input, value} con modelos no
presentes en el combo del nodo.
- suggestions: lista de acciones {kind, name, action, hint, ...} — una
por nodo o modelo faltante — describiendo que funcion usar.
- error: motivo si no se pudo consultar el servidor (ok=False).
"""
val = comfyui_validate_workflow(workflow, server=server)
if not val.get("ok"):
return {
"ok": False,
"missing_nodes": [],
"missing_models": [],
"suggestions": [],
"error": val.get("error", "validacion fallida"),
}
missing_nodes = val.get("missing_nodes", [])
missing_models = val.get("missing_models", [])
suggestions = []
for node in missing_nodes:
suggestions.append({
"kind": "node",
"name": node,
"action": "install_custom_node",
"hint": (
f"Nodo custom '{node}' ausente. Localiza su repo (ComfyUI-Manager "
f"DB o GitHub por el class_type) e instala con "
f"comfyui_install_custom_node('<repo_url>')."
),
})
for m in missing_models:
value = m.get("value")
suggestions.append({
"kind": "model",
"name": value,
"input": m.get("input"),
"node": m.get("node"),
"action": "search_and_download",
"hint": (
f"Modelo '{value}' ausente (input {m.get('input')}). Busca con "
f"comfyui_search_civitai_models(query='...') o en HuggingFace, "
f"luego comfyui_download_model(url, dest_subdir=...)."
),
})
return {
"ok": True,
"missing_nodes": missing_nodes,
"missing_models": missing_models,
"suggestions": suggestions,
"error": "",
}
if __name__ == "__main__":
import json
# Workflow con un nodo inexistente -> debe aparecer en missing_nodes.
wf = {
"1": {"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "dreamshaper_8.safetensors"}},
"2": {"class_type": "NodoQueNoExiste_XYZ", "inputs": {}},
}
print(json.dumps(comfyui_resolve_workflow_deps(wf), indent=2))
@@ -0,0 +1,80 @@
---
name: comfyui_search_civitai_models
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_search_civitai_models(query: str, *, types: str = \"Checkpoint\", base_model: str | None = None, sort: str = \"Highest Rated\", limit: int = 20, token: str | None = None) -> dict"
description: "Busca modelos/LoRAs en Civitai via su API publica GET /api/v1/models y normaliza cada resultado a {name, type, base_model, version_id, download_url, nsfw} (primera version del modelo). La busqueda publica funciona SIN token; el token solo sube el rate limit y desbloquea modelos restringidos. Impura: HTTP GET a civitai.com. Solo stdlib."
tags: [comfyui, ml, civitai, search, models, stable-diffusion, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: query
desc: "Texto de busqueda (nombre del modelo, ej. 'dreamshaper')."
- name: types
desc: "Tipo(s) de modelo Civitai, CSV: Checkpoint, LORA, TextualInversion, Controlnet, VAE, Upscaler, ... keyword-only."
- name: base_model
desc: "Filtra por modelo base (ej. 'SD 1.5', 'SDXL 1.0'). None no filtra. keyword-only."
- name: sort
desc: "Orden Civitai: 'Highest Rated', 'Most Downloaded', 'Newest'. keyword-only."
- name: limit
desc: "Numero maximo de resultados (1-100). keyword-only."
- name: token
desc: "API token de Civitai (header Authorization Bearer). Opcional: la busqueda publica funciona sin el. No hardcodear: pasar desde pass/vault. keyword-only."
output: "dict {ok, items, count, error}. items = lista de {name, type, base_model, version_id, download_url, nsfw} (primera version de cada modelo). ok=False con error si la peticion falla; una busqueda sin resultados devuelve ok=True con items=[] (no es error)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_search_civitai_models.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_search_civitai_models import comfyui_search_civitai_models
# Busqueda publica, sin token:
out = comfyui_search_civitai_models("dreamshaper", types="Checkpoint", limit=5)
for it in out["items"]:
print(it["name"], it["base_model"], "->", it["download_url"])
# out["items"][0] == {"name": "DreamShaper", "type": "Checkpoint",
# "base_model": "SD 1.5", "version_id": 128713,
# "download_url": "https://civitai.com/api/download/models/128713", "nsfw": False}
# Filtrar LoRAs SDXL, con token desde pass (si lo hubiera):
# import subprocess
# tok = subprocess.run(["pass","civitai/api-token"], capture_output=True, text=True).stdout.strip() or None
# loras = comfyui_search_civitai_models("detail", types="LORA", base_model="SDXL 1.0", token=tok)
```
El `download_url` que devuelve se pasa directo a `comfyui_download_model(url=...)`
para bajar el modelo a la carpeta correcta.
## Cuando usarla
Cuando necesites descubrir un checkpoint o LoRA por nombre/tema antes de
descargarlo: te da el `download_url` listo para `comfyui_download_model`. Encadena
con `comfyui_resolve_workflow_deps` para resolver los modelos que le faltan a un
workflow ajeno.
## Gotchas
- Impura: HTTP GET a `civitai.com`. Requiere conexion a internet (NO usa el server
local de ComfyUI, asi que no choca con reinicios del server).
- La busqueda publica funciona sin token. Sin token el rate limit es mas bajo y
algunos modelos restringidos no aparecen o su `download_url` exige login.
- `download_url` apunta a `api/download/models/<versionId>`: muchos modelos exigen
token para descargar aunque salgan en la busqueda (Civitai responde HTML de
login). `comfyui_download_model` detecta ese HTML y devuelve ok=False.
- Solo se devuelve la PRIMERA version de cada modelo (`modelVersions[0]`, la mas
reciente). Para versiones antiguas consulta la API por `version_id`.
- Una busqueda sin resultados NO es error: devuelve `ok=True, items=[], count=0`.
- No hardcodear el token: pasarlo desde `pass`/vault.
@@ -0,0 +1,101 @@
"""Busca modelos / LoRAs en Civitai via su API publica GET /api/v1/models.
Normaliza cada resultado a un dict pequeno y reutilizable
({name, type, base_model, version_id, download_url, nsfw}) tomando la primera
version del modelo. La busqueda publica funciona sin token; pasar un token solo
sube el rate limit y desbloquea modelos restringidos.
Impura: red (HTTP GET a civitai.com). Solo stdlib (urllib, json).
"""
import json
import urllib.error
import urllib.parse
import urllib.request
_API = "https://civitai.com/api/v1/models"
_TIMEOUT = 30.0
def comfyui_search_civitai_models(
query: str,
*,
types: str = "Checkpoint",
base_model: str | None = None,
sort: str = "Highest Rated",
limit: int = 20,
token: str | None = None,
) -> dict:
"""Busca modelos en Civitai y devuelve resultados normalizados.
Args:
query: texto de busqueda (nombre del modelo, ej. "dreamshaper").
types: tipo(s) de modelo, CSV. Valores Civitai: Checkpoint, LORA,
TextualInversion, Controlnet, VAE, Upscaler, ... keyword-only.
base_model: filtra por modelo base (ej. "SD 1.5", "SDXL 1.0"). None no
filtra. keyword-only.
sort: orden Civitai ("Highest Rated", "Most Downloaded", "Newest").
keyword-only.
limit: numero maximo de resultados (1-100). keyword-only.
token: API token de Civitai (header Authorization Bearer). Opcional: la
busqueda publica funciona sin el. No hardcodear: pasar desde
pass/vault. keyword-only.
Returns:
dict {ok, items, count, error}. items es una lista de
{name, type, base_model, version_id, download_url, nsfw} (la primera
version de cada modelo). ok=False con error si la peticion falla; una
busqueda sin resultados devuelve ok=True con items=[] (no es error).
"""
params = [
("query", query),
("limit", str(max(1, min(int(limit), 100)))),
("sort", sort),
]
for t in str(types).split(","):
t = t.strip()
if t:
params.append(("types", t))
if base_model:
for bm in (base_model if isinstance(base_model, (list, tuple)) else [base_model]):
params.append(("baseModels", str(bm)))
url = f"{_API}?{urllib.parse.urlencode(params)}"
headers = {"User-Agent": "fn-registry/comfyui_search_civitai_models"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:300]
return {"ok": False, "items": [], "count": 0,
"error": f"HTTP {exc.code} en {url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "items": [], "count": 0,
"error": f"no se pudo conectar a civitai.com: {exc.reason}"}
except json.JSONDecodeError as exc:
return {"ok": False, "items": [], "count": 0,
"error": f"respuesta no es JSON valido: {exc}"}
items = []
for model in data.get("items", []) or []:
versions = model.get("modelVersions") or []
v0 = versions[0] if versions else {}
items.append({
"name": model.get("name"),
"type": model.get("type"),
"base_model": v0.get("baseModel"),
"version_id": v0.get("id"),
"download_url": v0.get("downloadUrl"),
"nsfw": bool(model.get("nsfw", False)),
})
return {"ok": True, "items": items, "count": len(items), "error": ""}
if __name__ == "__main__":
out = comfyui_search_civitai_models("dreamshaper", limit=5)
print(out["ok"], out["count"])
for it in out["items"]:
print(f" {it['name']} [{it['base_model']}] v{it['version_id']} -> {it['download_url']}")
@@ -0,0 +1,70 @@
---
name: comfyui_submit_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_submit_workflow(workflow: dict, server: str = \"127.0.0.1:8188\", client_id: str | None = None, timeout: float = 30.0) -> dict"
description: "Envia un workflow (API format) a ComfyUI via POST /prompt. Devuelve la respuesta con prompt_id y number (posicion en cola). Si ComfyUI rechaza el workflow (HTTP 400) propaga el cuerpo con los detalles de validacion por nodo. Impura: HTTP POST, solo stdlib (urllib, json, uuid)."
tags: [comfyui, ml, image-generation, stable-diffusion, http, queue]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: workflow
desc: "dict en API format (resultado de comfyui_build_txt2img_workflow u otro builder). Claves = node_ids, valores con class_type + inputs."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: client_id
desc: "Identificador de cliente para correlar eventos. None genera un uuid4; el id usado se devuelve en la respuesta."
- name: timeout
desc: "Timeout de la peticion HTTP en segundos."
output: "dict de respuesta de ComfyUI con prompt_id (str, para comfyui_wait_result), number (int, posicion en cola), node_errors (dict) y la clave anadida client_id (str usado en la peticion)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_submit_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
)
resp = comfyui_submit_workflow(wf)
prompt_id = resp["prompt_id"] # pasalo a comfyui_wait_result
print(prompt_id, resp.get("number"))
```
O lanzable directo (build + submit) con: `./fn run comfyui_submit_workflow`.
## Cuando usarla
Tras construir un workflow con `comfyui_build_txt2img_workflow`, para encolarlo
en el servidor y obtener el `prompt_id`. Es el segundo paso del round-trip
build -> submit -> wait. Reutiliza el `client_id` que devuelve si vas a
correlar varios prompts del mismo cliente.
## Gotchas
- ComfyUI encola y devuelve de inmediato; NO espera a que termine la
generacion. Para recuperar el resultado usa `comfyui_wait_result` con el
prompt_id.
- Si el workflow es invalido (checkpoint inexistente, conexion mal tipada,
input fuera de rango) ComfyUI responde HTTP 400 y esta funcion lanza
RuntimeError con el cuerpo de validacion del nodo afectado. Leelo: el detalle
dice que nodo y que input fallo.
- Encolar tiene efecto secundario: arranca trabajo de GPU en el servidor. No es
idempotente — cada llamada encola un prompt nuevo.
@@ -0,0 +1,80 @@
"""Envia un workflow (API format) a un servidor ComfyUI via POST /prompt.
Funcion impura: hace red (HTTP POST). Solo stdlib (urllib, json, uuid).
ComfyUI encola el workflow y devuelve un dict con prompt_id (para seguir el
resultado con comfyui_wait_result), number (posicion en la cola) y node_errors.
Si el workflow es invalido, ComfyUI responde HTTP 400 con detalles de la
validacion por nodo: esta funcion los captura y los propaga en el error.
"""
import json
import urllib.error
import urllib.request
import uuid
def comfyui_submit_workflow(
workflow: dict,
server: str = "127.0.0.1:8188",
client_id: str | None = None,
timeout: float = 30.0,
) -> dict:
"""Encola un workflow en ComfyUI y devuelve la respuesta del servidor.
Args:
workflow: dict en API format (resultado de
comfyui_build_txt2img_workflow u otro builder). Las claves son
node_ids y cada valor tiene class_type + inputs.
server: host:port del servidor ComfyUI (sin esquema).
client_id: identificador de cliente para correlar eventos (WS/history).
Si es None, se genera un uuid4. Se incluye en la respuesta.
timeout: timeout de la peticion HTTP en segundos.
Returns:
dict con al menos prompt_id (str), number (int, posicion en cola) y
node_errors (dict). Se anade la clave "client_id" usada.
Raises:
RuntimeError: si ComfyUI rechaza el workflow (HTTP 400 con detalles de
validacion en el cuerpo), si no se puede conectar, o si la respuesta
no es JSON valido. El mensaje incluye el cuerpo del error.
"""
cid = client_id or str(uuid.uuid4())
body = json.dumps({"prompt": workflow, "client_id": cid}).encode()
url = f"http://{server}/prompt"
req = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
out = json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(
f"comfyui_submit_workflow: ComfyUI rechazo el workflow "
f"(HTTP {exc.code}): {detail}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"comfyui_submit_workflow: no se pudo conectar a {url}: {exc.reason}"
) from exc
except json.JSONDecodeError as exc:
raise RuntimeError(
f"comfyui_submit_workflow: respuesta no es JSON valido desde {url}: {exc}"
) from exc
out["client_id"] = cid
return out
if __name__ == "__main__":
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
resp = comfyui_submit_workflow(wf)
print(f"prompt_id={resp['prompt_id']} number={resp.get('number')}")
@@ -0,0 +1,69 @@
---
name: comfyui_validate_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_validate_workflow(workflow: dict, server: str = \"127.0.0.1:8188\", timeout: float = 30.0) -> dict"
description: "Valida un workflow ComfyUI (API format) contra el catalogo /object_info de un servidor vivo: cruza los class_type contra los nodos disponibles y los nombres de modelos (ckpt/lora/vae/controlnet/...) contra los combos enumerados de cada nodo. Devuelve nodos y modelos faltantes ANTES de encolar, evitando un HTTP 400. Compone comfyui_object_info. Impura: HTTP GET."
tags: [comfyui, ml, validation, workflow, stable-diffusion]
uses_functions: [comfyui_object_info_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: workflow
desc: "dict en API format ({node_id: {class_type, inputs}}) a validar."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
- name: timeout
desc: "Timeout de la consulta HTTP en segundos."
output: "dict {ok, valid, missing_nodes, missing_models, error}. ok = se pudo consultar el servidor; valid = sin nodos ni modelos faltantes; missing_nodes = class_type ausentes; missing_models = lista de {node, input, value} con valores de modelo fuera del combo; error = motivo si ok=False."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_validate_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_validate_workflow import comfyui_validate_workflow
wf = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat")
res = comfyui_validate_workflow(wf) # server 127.0.0.1:8188 vivo
# res == {"ok": True, "valid": True, "missing_nodes": [], "missing_models": [], "error": ""}
bad = comfyui_build_txt2img_workflow("no_existe.safetensors", "a cat")
res2 = comfyui_validate_workflow(bad)
# res2["valid"] == False
# res2["missing_models"] == [{"node": "4", "input": "ckpt_name", "value": "no_existe.safetensors"}]
```
El bloque de arriba se lanza con el python del venv. El `if __name__ == "__main__"` del archivo valida un txt2img de ejemplo contra el server local (`python/.venv/bin/python3 python/functions/ml/comfyui_validate_workflow.py`). Nota: `./fn run` posicional no aplica porque el primer arg es un dict (workflow), no un escalar de CLI.
## Cuando usarla
Siempre ANTES de `comfyui_submit_workflow`, sobre todo con workflows importados de
internet (JSON/PNG ajenos) o que mezclen checkpoints/LoRAs que quiza no tengas
descargados. Te dice exactamente que nodos custom faltan (a instalar) y que
modelos faltan (a descargar) sin gastar un encolado fallido en el servidor.
## Gotchas
- Impura: hace HTTP GET a `/object_info`. Requiere el servidor vivo; si esta caido
o reiniciandose, devuelve `{ok: False, error: ...}` (no lanza). Reintenta tu.
- `missing_models` solo cubre inputs de modelo conocidos (ckpt_name, lora_name,
vae_name, control_net_name, model_name, unet_name, clip_name, style_model_name,
gligen_name) cuyo valor sea un string fuera del combo enumerado. No valida rangos
numericos ni tipos de conexion.
- Detecta nodos custom faltantes por class_type ausente en /object_info, pero NO
resuelve de que repo instalarlos (eso es trabajo de resolve_workflow_deps, P1).
- `sampler_name`/`scheduler` invalidos NO se reportan como missing_models (no son
modelos); el servidor los rechazaria al encolar.
@@ -0,0 +1,110 @@
"""Valida un workflow ComfyUI contra el catalogo /object_info del servidor.
Cruza los class_type del workflow contra los nodos disponibles y los nombres de
modelos (checkpoints, loras, vae, controlnet, upscale) contra los combos
enumerados de cada nodo. Asi se detectan nodos o modelos faltantes ANTES de
encolar (POST /prompt), evitando un HTTP 400 del servidor.
Compone comfyui_object_info (no reimplementa la consulta HTTP).
Impura: red (HTTP GET via comfyui_object_info). Solo stdlib.
"""
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_object_info import comfyui_object_info # noqa: E402
# inputs cuyo valor es el nombre (string) de un asset/modelo en disco
_MODEL_INPUTS = {
"ckpt_name",
"lora_name",
"vae_name",
"control_net_name",
"model_name",
"unet_name",
"clip_name",
"style_model_name",
"gligen_name",
}
def comfyui_validate_workflow(
workflow: dict,
server: str = "127.0.0.1:8188",
timeout: float = 30.0,
) -> dict:
"""Valida un workflow API format contra un servidor ComfyUI vivo.
Args:
workflow: dict en API format ({node_id: {class_type, inputs}}).
server: host:port del servidor ComfyUI (sin esquema).
timeout: timeout de la consulta HTTP en segundos.
Returns:
dict {ok, valid, missing_nodes, missing_models, error}:
- ok: la validacion se pudo ejecutar (el servidor respondio).
- valid: el workflow no tiene nodos ni modelos faltantes.
- missing_nodes: lista de class_type ausentes en el servidor.
- missing_models: lista de {node, input, value} con valores de modelo
no presentes en el combo enumerado correspondiente.
- error: mensaje si no se pudo consultar el servidor (ok=False).
"""
try:
obj_info = comfyui_object_info(server=server, timeout=timeout)
except RuntimeError as exc:
return {
"ok": False,
"valid": False,
"missing_nodes": [],
"missing_models": [],
"error": str(exc),
}
missing_nodes = []
missing_models = []
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
ctype = node.get("class_type")
if ctype not in obj_info:
missing_nodes.append(ctype)
continue
spec = obj_info[ctype].get("input", {})
allowed = {}
for section in ("required", "optional"):
for name, decl in (spec.get(section) or {}).items():
if isinstance(decl, list) and decl and isinstance(decl[0], list):
allowed[name] = set(decl[0])
for in_name, val in node.get("inputs", {}).items():
if in_name in _MODEL_INPUTS and isinstance(val, str):
opts = allowed.get(in_name)
if opts is not None and val not in opts:
missing_models.append(
{"node": nid, "input": in_name, "value": val}
)
# dedup missing_nodes preservando orden
seen = set()
missing_nodes = [c for c in missing_nodes if not (c in seen or seen.add(c))]
valid = not missing_nodes and not missing_models
return {
"ok": True,
"valid": valid,
"missing_nodes": missing_nodes,
"missing_models": missing_models,
"error": "",
}
if __name__ == "__main__":
import json
sys.path.insert(0, _THIS_DIR)
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
wf = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat")
print(json.dumps(comfyui_validate_workflow(wf), indent=2))
@@ -0,0 +1,73 @@
---
name: comfyui_wait_result
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_wait_result(prompt_id: str, server: str = \"127.0.0.1:8188\", timeout: float = 180.0, poll_interval: float = 1.0) -> dict"
description: "Sondea GET /history/{prompt_id} hasta que un prompt ComfyUI completa (status.completed o status_str success/error) o se agota el timeout. Devuelve los outputs por nodo (node_id -> {images: [...]}). Polling como mecanismo principal (no WebSocket). Impura: HTTP GET en bucle + sleep, solo stdlib."
tags: [comfyui, ml, image-generation, stable-diffusion, http, polling]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: prompt_id
desc: "id devuelto por comfyui_submit_workflow (clave 'prompt_id' de su respuesta)."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: timeout
desc: "Maximo de segundos a esperar antes de lanzar TimeoutError."
- name: poll_interval
desc: "Segundos entre sondeos de /history."
output: "dict de outputs {node_id: {'images': [{'filename', 'subfolder', 'type'}, ...]}} tal como ComfyUI los expone en history[prompt_id]['outputs']. Para un txt2img, el nodo SaveImage ('9') trae el PNG. Puede contener otros tipos (gifs, texto) segun los nodos del workflow."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_wait_result.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
)
pid = comfyui_submit_workflow(wf)["prompt_id"]
outputs = comfyui_wait_result(pid, timeout=240)
for node_id, out in outputs.items():
for img in out.get("images", []):
print(img["filename"]) # ej. comfy_00001_.png en ~/ComfyUI/output/
```
O lanzable directo (build + submit + wait) con: `./fn run comfyui_wait_result`.
## Cuando usarla
Tercer y ultimo paso del round-trip: tras `comfyui_submit_workflow`, para
bloquear hasta que la generacion termine y recuperar las rutas de los PNG
generados. Usala cuando quieras el resultado fija (no streaming de progreso paso
a paso) — es portable porque solo depende de HTTP, no de websocket-client.
## Gotchas
- Bloquea el hilo (sondea + duerme). Para varias generaciones en paralelo,
encola todas con submit y luego espera cada prompt_id, o usa hilos.
- El timeout por defecto (180s) puede quedarse corto en GPUs lentas o workflows
pesados (muchos steps, alta resolucion, upscalers). Sube `timeout` segun el
caso. Lanza TimeoutError si se agota.
- Lanza RuntimeError si la ejecucion termina con status_str "error" (el detalle
del fallo va en el mensaje) o si no se puede conectar al servidor.
- Devuelve metadatos de los PNG (filename, subfolder, type), NO los bytes de la
imagen. Los archivos quedan en la carpeta output/ del servidor; para leerlos
desde otra maquina usa GET /view?filename=...&subfolder=...&type=output.
@@ -0,0 +1,98 @@
"""Sondea GET /history/{prompt_id} hasta que un workflow ComfyUI termina.
Funcion impura: hace red (HTTP GET en bucle) y duerme entre sondeos. Solo
stdlib (urllib, json, time).
Usa polling de /history como mecanismo principal (no WebSocket): es mas robusto
porque no depende de websocket-client, que no esta garantizado en el venv. Para
saber si el resultado esta listo (no streaming de progreso paso a paso) el
polling de /history es suficiente y portable.
"""
import json
import time
import urllib.error
import urllib.request
def comfyui_wait_result(
prompt_id: str,
server: str = "127.0.0.1:8188",
timeout: float = 180.0,
poll_interval: float = 1.0,
) -> dict:
"""Espera a que ComfyUI termine de ejecutar un prompt y devuelve sus outputs.
Sondea GET /history/{prompt_id} cada poll_interval segundos hasta que
status.completed es True o status.status_str es "success"/"error", o hasta
agotar el timeout.
Args:
prompt_id: id devuelto por comfyui_submit_workflow.
server: host:port del servidor ComfyUI (sin esquema).
timeout: maximo de segundos a esperar antes de fallar.
poll_interval: segundos entre sondeos.
Returns:
dict de outputs {node_id: {"images": [{filename, subfolder, type}, ...]}}
tal como ComfyUI los expone en history[prompt_id]["outputs"]. Puede
contener otros tipos de output (gifs, texto) segun los nodos del
workflow.
Raises:
TimeoutError: si se agota el timeout sin que el prompt complete.
RuntimeError: si la ejecucion termina con status_str "error", si no se
puede conectar, o si la respuesta no es JSON valido.
"""
url = f"http://{server}/history/{prompt_id}"
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
hist = json.loads(resp.read())
except urllib.error.URLError as exc:
raise RuntimeError(
f"comfyui_wait_result: no se pudo conectar a {url}: {exc.reason}"
) from exc
except json.JSONDecodeError as exc:
raise RuntimeError(
f"comfyui_wait_result: respuesta no es JSON valido desde {url}: {exc}"
) from exc
entry = hist.get(prompt_id)
if entry:
status = entry.get("status", {})
status_str = status.get("status_str")
if status_str == "error":
raise RuntimeError(
f"comfyui_wait_result: ejecucion fallo para {prompt_id}: "
f"{json.dumps(status)[:500]}"
)
if status.get("completed") or status_str == "success":
return entry.get("outputs", {})
time.sleep(poll_interval)
raise TimeoutError(
f"comfyui_wait_result: timeout de {timeout}s esperando {prompt_id}"
)
if __name__ == "__main__":
import sys
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from comfyui_submit_workflow import comfyui_submit_workflow
wf = comfyui_build_txt2img_workflow(
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
positive="a red apple on a wooden table, sharp focus",
negative="blurry, low quality",
steps=20,
seed=42,
)
resp = comfyui_submit_workflow(wf)
pid = resp["prompt_id"]
print(f"esperando prompt_id={pid} ...", file=sys.stderr)
outputs = comfyui_wait_result(pid)
for node_id, out in outputs.items():
for img in out.get("images", []):
print(f"OUTPUT node={node_id} filename={img['filename']}")
@@ -0,0 +1,89 @@
---
name: comfyui_image_to_3d_oneshot
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.0.0"
signature: "def comfyui_image_to_3d_oneshot(image_path: str, *, server: str = \"127.0.0.1:8188\", variant: str = \"mini\", dest: str | None = None, wait_timeout: float = 600.0, **gen) -> dict"
description: "Pipeline imagen en disco -> malla 3D GLB en una sola llamada. Sube la imagen al input/ de ComfyUI (POST /upload/image), construye el workflow Hunyuan3D-2 nativo de 9 nodos, lo encola, espera y descarga la malla. Compone comfyui_build_image_to_3d_workflow + comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_mesh. Promocion de secuencia (issue 0087). Impuro: HTTP + disco."
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, pipeline]
uses_functions:
- comfyui_build_image_to_3d_workflow_py_ml
- comfyui_submit_workflow_py_ml
- comfyui_wait_result_py_ml
- comfyui_fetch_output_mesh_py_ml
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_image_to_3d_oneshot.py"
params:
- name: image_path
desc: "Ruta local de la imagen de entrada (PNG/JPG). Se sube al input/ del servidor."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: variant
desc: "'mini' (default), 'standard' o 'mv'; elige el checkpoint Hunyuan3D-2. El modelo debe estar ya instalado (comfyui_install_3d_model). keyword-only."
- name: dest
desc: "Ruta destino de la malla (None = cwd; dir = dentro; archivo = ahi). keyword-only."
- name: wait_timeout
desc: "Segundos maximos esperando a que el server termine la reconstruccion 3D (carga del modelo + difusion + decode). keyword-only."
- name: gen
desc: "Parametros de generacion pasados a comfyui_build_image_to_3d_workflow (resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold, sampler_name, scheduler, filename_prefix). **kwargs."
output: "dict {ok, mesh_path, faces, prompt_id, error}. mesh_path = ruta local de la malla descargada; faces = numero de caras si trimesh esta disponible (si no, None); prompt_id = id del trabajo en ComfyUI. Si falla, ok=False y error explica en que paso."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_image_to_3d_oneshot import comfyui_image_to_3d_oneshot
# Una imagen en disco -> un .glb en /tmp/meshes, en una sola llamada.
res = comfyui_image_to_3d_oneshot(
os.path.expanduser("~/ComfyUI/input/3d_src_robot_00001_.png"),
dest="/tmp/meshes",
variant="mini",
seed=42,
)
# res == {"ok": True, "mesh_path": "/tmp/meshes/3d_mesh_00001_.glb",
# "faces": 1668040, "prompt_id": "....", "error": ""}
```
CLI directo (el modelo mini debe estar instalado):
```bash
$HOME/fn_registry/python/.venv/bin/python3 \
python/functions/pipelines/comfyui_image_to_3d_oneshot.py \
~/ComfyUI/input/3d_src_robot_00001_.png
```
## Cuando usarla
Cuando tengas una imagen de un objeto (idealmente aislado sobre fondo plano) y
quieras su malla 3D GLB sin montar el grafo de nodos ni encadenar submit/wait/fetch
a mano. Es la operacion de un solo paso del flujo imagen->3D nativo de ComfyUI;
ideal para scripts y agentes. Para tunear el workflow nodo a nodo, usa las
funciones sueltas (`comfyui_build_image_to_3d_workflow` + submit/wait/fetch).
## Gotchas
- Impuro: sube la imagen (HTTP), dispara la GPU del servidor y escribe la malla en
disco. Requiere el server ComfyUI vivo en `server` y el checkpoint de la variante
ya instalado (`comfyui_install_3d_model` antes; este pipeline NO lo instala).
- La reconstruccion 3D tarda ~60 s en una RTX 3070 (carga del modelo entero a GPU +
difusion + decode de voxel). Sube `wait_timeout` si el server arranca en frio o si
usas `resolution`/`octree_resolution` altos.
- Si el server esta ocupado con otro trabajo, el wait puede agotar el timeout:
reintenta o sube `wait_timeout`. No reinicia ni desencola nada.
- `faces` es best-effort: solo se calcula si `trimesh` esta en el venv. Si no, sale
`None` (la malla igual se descarga bien; comprueba `mesh_path`/`bytes`).
- Salida **shape-only** (sin color/textura): es el camino nativo de Hunyuan3D-2.
- El upload usa POST /upload/image con `overwrite=true`: si subes dos imagenes con
el mismo basename, la segunda pisa a la primera en el input/ del servidor.
@@ -0,0 +1,181 @@
"""comfyui_image_to_3d_oneshot — imagen en disco -> malla 3D GLB en una sola llamada.
Promocion de la secuencia repetida (issue 0087): subir imagen al input/ de ComfyUI
-> construir el workflow Hunyuan3D-2 nativo -> encolar -> esperar -> descargar la
malla. Compone funciones del registry del grupo `comfyui`:
comfyui_build_image_to_3d_workflow_py_ml (workflow de 9 nodos)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_mesh_py_ml (GET /view -> disco)
El upload de la imagen al `input/` del servidor (POST /upload/image) es logica
especifica de este pipeline y va inline (no es una funcion reutilizable del
registry todavia).
Pipeline impuro: red (HTTP) + escritura en disco.
"""
from __future__ import annotations
import json
import mimetypes
import os
import sys
import urllib.error
import urllib.request
import uuid
# Importa las funciones del registry (mismo arbol python/functions).
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _FUNCTIONS_ROOT not in sys.path:
sys.path.insert(0, _FUNCTIONS_ROOT)
from ml.comfyui_build_image_to_3d_workflow import comfyui_build_image_to_3d_workflow
from ml.comfyui_fetch_output_mesh import comfyui_fetch_output_mesh
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
# variant -> nombre del checkpoint que espera ImageOnlyCheckpointLoader.
_VARIANT_CKPT = {
"mini": "hunyuan3d-dit-v2-mini.safetensors",
"standard": "hunyuan3d-dit-v2-0.safetensors",
"mv": "hunyuan3d-dit-v2-mv.safetensors",
}
def _upload_image(image_path: str, server: str, timeout: float = 60.0) -> str:
"""Sube una imagen local al input/ de ComfyUI via POST /upload/image.
Construye un multipart/form-data a mano (solo stdlib). Devuelve el nombre del
archivo tal como queda en el servidor (para el nodo LoadImage). Lanza
RuntimeError si falla.
"""
with open(image_path, "rb") as fh:
content = fh.read()
name = os.path.basename(image_path)
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
boundary = f"----fnRegistry{uuid.uuid4().hex}"
crlf = "\r\n"
pre = (
f"--{boundary}{crlf}"
f'Content-Disposition: form-data; name="image"; filename="{name}"{crlf}'
f"Content-Type: {ctype}{crlf}{crlf}"
).encode()
mid = (
f"{crlf}--{boundary}{crlf}"
f'Content-Disposition: form-data; name="overwrite"{crlf}{crlf}true'
f"{crlf}--{boundary}{crlf}"
f'Content-Disposition: form-data; name="type"{crlf}{crlf}input'
f"{crlf}--{boundary}--{crlf}"
).encode()
body = pre + content + mid
url = f"http://{server}/upload/image"
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
out = json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")[:200]
raise RuntimeError(f"upload HTTP {exc.code} en {url}: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"no se pudo conectar a {url}: {exc.reason}") from exc
server_name = out.get("name", name)
subfolder = out.get("subfolder", "")
return f"{subfolder}/{server_name}" if subfolder else server_name
def comfyui_image_to_3d_oneshot(
image_path: str,
*,
server: str = "127.0.0.1:8188",
variant: str = "mini",
dest: str | None = None,
wait_timeout: float = 600.0,
**gen,
) -> dict:
"""Reconstruye una malla 3D desde una imagen en disco, end-to-end.
Args:
image_path: ruta local de la imagen de entrada (PNG/JPG). Se sube al
input/ del servidor.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
variant: "mini" (default), "standard" o "mv"; elige el checkpoint
Hunyuan3D-2. El modelo debe estar ya instalado (comfyui_install_3d_model).
keyword-only.
dest: ruta destino de la malla (None = cwd; dir = dentro; archivo = ahi).
keyword-only.
wait_timeout: segundos maximos esperando a que el server termine la
reconstruccion 3D (carga del modelo + difusion + decode). keyword-only.
**gen: parametros de generacion pasados a comfyui_build_image_to_3d_workflow
(resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold,
sampler_name, scheduler, filename_prefix).
Returns:
dict {ok, mesh_path, faces, prompt_id, error}. mesh_path = ruta local de la
malla descargada; faces = numero de caras si trimesh esta disponible (si no,
None); prompt_id = id del trabajo en ComfyUI. Si falla, ok=False y error
explica en que paso.
"""
if variant not in _VARIANT_CKPT:
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": "",
"error": f"variant {variant!r} no valida; usa {sorted(_VARIANT_CKPT)}"}
if not os.path.isfile(os.path.expanduser(image_path)):
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": "",
"error": f"imagen no existe: {image_path}"}
image_path = os.path.expanduser(image_path)
ckpt_name = _VARIANT_CKPT[variant]
# 1. Subir la imagen al input/ del servidor.
try:
image_name = _upload_image(image_path, server)
except RuntimeError as exc:
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": "",
"error": f"upload de imagen fallo: {exc}"}
# 2. Construir el workflow imagen->3D (funcion pura del registry).
workflow = comfyui_build_image_to_3d_workflow(image_name, ckpt_name, **gen)
# 3. Encolar.
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
except (RuntimeError, KeyError) as exc:
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": "",
"error": f"submit fallo: {exc}"}
# 4. Esperar a que termine.
try:
comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError) as exc:
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": prompt_id,
"error": f"wait fallo: {exc}"}
# 5. Descargar la malla.
fetched = comfyui_fetch_output_mesh(prompt_id, server=server, dest=dest)
if not fetched.get("ok"):
return {"ok": False, "mesh_path": "", "faces": None, "prompt_id": prompt_id,
"error": f"fetch de malla fallo: {fetched.get('error')}"}
mesh_path = fetched["path"]
# 6. Caras (best-effort; trimesh puede no estar en el venv).
faces = None
try:
import trimesh # type: ignore
mesh = trimesh.load(mesh_path, force="mesh")
faces = int(len(mesh.faces))
except Exception: # noqa: BLE001 — trimesh ausente o malla ilegible: faces=None.
faces = None
return {"ok": True, "mesh_path": mesh_path, "faces": faces,
"prompt_id": prompt_id, "error": ""}
if __name__ == "__main__":
img = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser(
"~/ComfyUI/input/3d_src_robot_00001_.png")
res = comfyui_image_to_3d_oneshot(img, dest="/tmp/comfy_oneshot")
print(json.dumps(res, indent=2))
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+180
View File
@@ -0,0 +1,180 @@
{
"1": {
"class_type": "Hy3DLoadMesh",
"inputs": {
"glb_path": "/home/enmanuel/ComfyUI/output/fox_mv_shape_00001_.glb"
}
},
"2": {
"class_type": "Hy3DPostprocessMesh",
"inputs": {
"trimesh": [
"1",
0
],
"remove_floaters": true,
"remove_degenerate_faces": true,
"reduce_faces": true,
"max_facenum": 40000,
"smooth_normals": false
}
},
"3": {
"class_type": "Hy3DMeshUVWrap",
"inputs": {
"trimesh": [
"2",
0
]
}
},
"4": {
"class_type": "LoadImage",
"inputs": {
"image": "fox_front.png"
}
},
"5": {
"class_type": "DownloadAndLoadHy3DPaintModel",
"inputs": {
"model": "hunyuan3d-paint-v2-0"
}
},
"6": {
"class_type": "DownloadAndLoadHy3DDelightModel",
"inputs": {
"model": "hunyuan3d-delight-v2-0"
}
},
"7": {
"class_type": "Hy3DCameraConfig",
"inputs": {
"camera_azimuths": "0, 90, 180, 270",
"camera_elevations": "0, 0, 0, 0",
"view_weights": "1, 0.5, 0.5, 0.5",
"camera_distance": 1.45,
"ortho_scale": 1.2
}
},
"8": {
"class_type": "Hy3DRenderMultiView",
"inputs": {
"trimesh": [
"3",
0
],
"render_size": 1024,
"texture_size": 1024,
"camera_config": [
"7",
0
],
"normal_space": "world"
}
},
"9": {
"class_type": "Hy3DDelightImage",
"inputs": {
"delight_pipe": [
"6",
0
],
"image": [
"4",
0
],
"steps": 50,
"width": 512,
"height": 512,
"cfg_image": 1.0,
"seed": 42
}
},
"10": {
"class_type": "Hy3DSampleMultiView",
"inputs": {
"pipeline": [
"5",
0
],
"ref_image": [
"9",
0
],
"normal_maps": [
"8",
0
],
"position_maps": [
"8",
1
],
"view_size": 512,
"steps": 25,
"seed": 0,
"camera_config": [
"7",
0
]
}
},
"11": {
"class_type": "Hy3DBakeFromMultiview",
"inputs": {
"images": [
"10",
0
],
"renderer": [
"8",
2
],
"camera_config": [
"7",
0
]
}
},
"12": {
"class_type": "Hy3DMeshVerticeInpaintTexture",
"inputs": {
"texture": [
"11",
0
],
"mask": [
"11",
1
],
"renderer": [
"11",
2
]
}
},
"13": {
"class_type": "Hy3DApplyTexture",
"inputs": {
"texture": [
"12",
0
],
"renderer": [
"12",
2
]
}
},
"14": {
"class_type": "Hy3DExportMesh",
"inputs": {
"trimesh": [
"13",
0
],
"filename_prefix": "3D/fox_mv_textured",
"file_format": "glb",
"save_file": true
}
}
}
+178
View File
@@ -0,0 +1,178 @@
{
"ckpt": {
"class_type": "ImageOnlyCheckpointLoader",
"inputs": {
"ckpt_name": "hunyuan3d-dit-v2-mv.safetensors"
}
},
"img_front": {
"class_type": "LoadImage",
"inputs": {
"image": "fox_front.png"
}
},
"enc_front": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": [
"ckpt",
1
],
"image": [
"img_front",
0
],
"crop": "center"
}
},
"img_left": {
"class_type": "LoadImage",
"inputs": {
"image": "fox_left.png"
}
},
"enc_left": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": [
"ckpt",
1
],
"image": [
"img_left",
0
],
"crop": "center"
}
},
"img_back": {
"class_type": "LoadImage",
"inputs": {
"image": "fox_back.png"
}
},
"enc_back": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": [
"ckpt",
1
],
"image": [
"img_back",
0
],
"crop": "center"
}
},
"img_right": {
"class_type": "LoadImage",
"inputs": {
"image": "fox_right.png"
}
},
"enc_right": {
"class_type": "CLIPVisionEncode",
"inputs": {
"clip_vision": [
"ckpt",
1
],
"image": [
"img_right",
0
],
"crop": "center"
}
},
"cond": {
"class_type": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"front": [
"enc_front",
0
],
"left": [
"enc_left",
0
],
"back": [
"enc_back",
0
],
"right": [
"enc_right",
0
]
}
},
"latent": {
"class_type": "EmptyLatentHunyuan3Dv2",
"inputs": {
"resolution": 3072,
"batch_size": 1
}
},
"ksampler": {
"class_type": "KSampler",
"inputs": {
"model": [
"ckpt",
0
],
"positive": [
"cond",
0
],
"negative": [
"cond",
1
],
"latent_image": [
"latent",
0
],
"seed": 42,
"steps": 30,
"cfg": 5.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0
}
},
"vae": {
"class_type": "VAEDecodeHunyuan3D",
"inputs": {
"samples": [
"ksampler",
0
],
"vae": [
"ckpt",
2
],
"num_chunks": 8000,
"octree_resolution": 256
}
},
"mesh": {
"class_type": "VoxelToMeshBasic",
"inputs": {
"voxel": [
"vae",
0
],
"threshold": 0.6
}
},
"save": {
"class_type": "SaveGLB",
"inputs": {
"mesh": [
"mesh",
0
],
"filename_prefix": "fox_mv_shape"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"loader": {"class_type":"ImageOnlyCheckpointLoader","inputs":{"ckpt_name":"stable_zero123.ckpt"}},
"img": {"class_type":"LoadImage","inputs":{"image":"fox_front.png"}},
"cond": {"class_type":"StableZero123_Conditioning_Batched",
"inputs":{"clip_vision":["loader",1],"init_image":["img",0],"vae":["loader",2],
"width":256,"height":256,"batch_size":4,"elevation":0.0,"azimuth":-90.0,
"elevation_batch_increment":0.0,"azimuth_batch_increment":90.0}},
"ksampler":{"class_type":"KSampler",
"inputs":{"model":["loader",0],"positive":["cond",0],"negative":["cond",1],
"latent_image":["cond",2],"seed":42,"steps":20,"cfg":4.0,
"sampler_name":"euler","scheduler":"normal","denoise":1.0}},
"vae": {"class_type":"VAEDecode","inputs":{"samples":["ksampler",0],"vae":["loader",2]}},
"save": {"class_type":"SaveImage","inputs":{"images":["vae",0],"filename_prefix":"fox_views"}}
}