29 Commits

Author SHA1 Message Date
egutierrez c0b2dce3b0 feat(ml): auto-commit con 26 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 04:02:54 +02:00
egutierrez ff41f4f053 feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:52:51 +02:00
egutierrez f686b338d6 chore: auto-commit (14 archivos)
- docs/capabilities/comfyui.md
- python/functions/ml/comfyui_build_image_to_3d_workflow.md
- python/functions/ml/comfyui_build_image_to_3d_workflow.py
- python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py
- python/functions/ml/comfyui_build_facedetailer_workflow.md
- python/functions/ml/comfyui_build_facedetailer_workflow.py
- python/functions/ml/comfyui_build_hires_fix_workflow.md
- python/functions/ml/comfyui_build_hires_fix_workflow.py
- python/functions/ml/tests/test_comfyui_build_facedetailer_workflow.py
- python/functions/ml/tests/test_comfyui_build_hires_fix_workflow.py
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:34:10 +02:00
egutierrez 3823a28d1c feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 02:05:43 +02:00
egutierrez 337f75b527 chore: auto-commit (5 archivos)
- docs/capabilities/comfyui.md
- python/functions/ml/comfyui_import_workflow_json.md
- python/functions/ml/comfyui_import_workflow_json.py
- python/functions/pipelines/comfyui_text_to_3d_oneshot.md
- python/functions/pipelines/comfyui_text_to_3d_oneshot.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:52:46 +02:00
egutierrez d3f05a19a5 feat(ml): auto-commit con 11 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:39:30 +02:00
egutierrez d7245efa59 feat(ml): auto-commit con 20 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:26:38 +02:00
egutierrez 1311c7e585 feat(ml): auto-commit con 7 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:16:37 +02:00
egutierrez db4f454f8a chore: auto-commit (1 archivos)
- .claude/commands/ausente.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 00:59:54 +02:00
egutierrez f12272d002 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>
2026-06-24 00:30:30 +02:00
egutierrez 495f545ec1 chore: untrack gitlinks fantasma cpp/apps/{chart_demo,shaders_lab}
Eran gitlinks (160000) en HEAD del padre sin entrada en .gitmodules,
restos del layout legacy cpp/apps/ (deprecado tras issue 0096, las apps
C++ viven ahora en apps/). Hacian fallar 'git submodule update' en cada
/full-git-pull. El sub-repo real shaders_lab vive sano en apps/shaders_lab;
chart_demo no existe en disco. Anadido cpp/apps/*/ al .gitignore para que
no recurra (regla apps_subrepo.md: el padre nunca trackea contenido de
artefactos hijos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:24:17 +02:00
egutierrez f34badb500 Merge remote-tracking branch 'origin/master' 2026-06-23 17:49:49 +02:00
egutierrez 3289c67986 chore: auto-commit (2 archivos)
- .claude/settings.local.json
- cpp/framework/app_base.cpp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 17:49:47 +02:00
egutierrez bcc1fe1738 feat(captacion_clientes): scraping freelance en perfil headless dedicado, no chromium-personal
El monitor de captación scrapeaba Workana sobre el navegador personal del
usuario (chromium-personal, CDP 9222), interfiriendo con su navegación. El
scraping CDP debe correr siempre en un perfil headless dedicado.

- Nuevo pipeline monitor_freelance_projects_headless: levanta un Chromium
  headless aislado con perfil dedicado (~/.config/fn_scrape_chrome, CDP 9334)
  vía systemd-run, ejecuta monitor_freelance_projects contra ese puerto y
  cierra la instancia al terminar (finally). Reutiliza el patrón de lifecycle
  de ingest_market_trends_headless. Reutiliza un CDP vivo si el puerto ya
  responde (no cierra lo ajeno).
- scrape_workana_projects y monitor_freelance_projects: default de `port`
  cambiado de 9222 (chromium-personal) a 9334 (perfil dedicado). Default seguro:
  correr a pelo sin Chrome en 9334 falla limpio, no contamina el 9222 personal.

Verificado: el wrapper arranca headless en 9334, scrapea 8 proyectos reales de
Workana, cierra la instancia (9334 muerto, sin proceso colgado) y deja el 9222
personal intacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:11:26 +02:00
egutierrez 7619347be8 Merge orq/mcp-crud-ids: doc orchestration fleet_list identifica por pane_id (report 0008) 2026-06-22 12:07:54 +02:00
egutierrez f55e41cf74 docs(orchestration): fleet_list identifies agents by pane_id, not tmux_window
Reflects the orchestrator_mcp change: the MCP fleet_list payload now surfaces
pane_id ("%N", the stable per-pane id) and omits tmux_window ("@N"), which
migrates with the focus swap. Documents that focus/send-keys(nudge)/kill
resolve the live window on demand against tmux, and that the nudge reads
tmux_window from the fleetview binary (which keeps it as an internal field),
never from the MCP payload. The binary's list --json field list now mentions
pane_id as the identifier alongside the internal tmux_window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:03:50 +02:00
egutierrez e2e8669edf Merge orq/sql-connect: mssql_connect + mssql_query + run_mssql_query pipeline, grupo sql-connect (report 0007) 2026-06-22 11:32:47 +02:00
egutierrez 86d68dc9f0 feat(infra): conexion y consulta directa a SQL Server (Navision) via pymssql
Grupo de capacidad nuevo 'sql-connect' (3 funciones) para conectar a un
Microsoft SQL Server (donde corre Navision) y consultar directamente, en
lugar del ida y vuelta manual de pegar CSVs.

- mssql_connect_py_infra: abre conexion pymssql (login_timeout acotado,
  credenciales por argumento, RuntimeError claro si falla).
- mssql_query_py_infra: SELECT parametrizada con binding seguro (sin
  inyeccion) sobre conexion abierta; devuelve {columns, rows, row_count};
  0 filas -> lista vacia; max_rows con fetchmany; read-only.
- run_mssql_query_py_pipelines: one-shot que compone connect+query y cierra
  siempre; CLI imprime JSON o CSV; contrasena desde env var (pass).

Pagina madre docs/capabilities/sql-connect.md + fila en INDEX.md.
Dependencia pymssql>=2.3.13 anadida a python/pyproject.toml + uv.lock.
Tests mock-based (11) verdes; error path verificado end-to-end contra el
driver real (host inalcanzable -> RuntimeError, acotado por login_timeout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:29:49 +02:00
egutierrez b18759823d Merge orq/equal-skill: /equal espejo de requisitos (report 0005) 2026-06-22 11:21:31 +02:00
egutierrez a59d50238d feat(commands): añadir /equal — espejo de requisitos para confirmar alineación
Reformula la última tarea pedida de forma detallada y estructurada
(objetivo, alcance, entregables, supuestos, criterios de aceptación,
fuera de alcance, dudas) para que usuario y Claude confirmen alineación
antes de ejecutar. No ejecuta la tarea: solo refleja y pregunta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:30 +02:00
egutierrez f17d957a8f docs(orquestador): nombrar cada secundario (--title + goal del sidebar) para distinguirlos
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:21 +02:00
egutierrez c1f355ffa5 Merge orq/fleet-detect: detect_fleet_context ($TMUX) + spawn auto-detecta socket + hook CONTEXTO FLEET + doctrina (report 0041) 2026-06-21 21:55:11 +02:00
egutierrez 237f763c19 Merge orq/img3d-registry-funcs: promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d, report 0040) 2026-06-21 21:51:13 +02:00
agent bf67ff3180 docs(orquestador): deteccion de flota por $TMUX, kitty solo fuera de tmux
orquestador.md + orchestration.md: la deteccion de 'estoy en una flota' se hace
por $TMUX (via detect_fleet_context), NO por $FLEET_SOCKET (fragil). kitty es
fallback SOLO cuando in_tmux=false. spawn_fleet_agent auto-detecta el socket
(ya no hace falta pasar --socket/--session). Documenta la linea CONTEXTO FLEET
del hook y anade detect_fleet_context al catalogo del grupo orchestration.
2026-06-21 21:50:32 +02:00
agent 03fc0461fa feat(hook): inyectar CONTEXTO FLEET con socket/session al orquestador
hook_fleet_state_inject.sh ahora, ademas de MODO ORQUESTADOR, llama a
detect_fleet_context (por $TMUX) e inyecta una linea CONTEXTO FLEET con
socket/session + recordatorio de usar spawn_fleet_agent (nunca kitty) cuando
in_fleet=true. No depende del venv (solo bash+tmux) y se emite antes del bloque
FLEET-STATE. Degrada limpio: si el detector falta o $TMUX esta vacia, no emite
la linea y el turno sigue intacto.
2026-06-21 21:50:32 +02:00
agent a1105dc4c5 feat(infra): spawn_fleet_agent auto-detecta socket/session de $TMUX
--socket/--session ahora opcionales: si no se pasan, se auto-detectan del
contexto tmux ($TMUX) via detect_fleet_context. Los explicitos siguen
primando. Aborta (exit 2) solo si tras auto-detectar siguen vacios (no hay
tmux). Elimina el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
estar en la flota. Bump v1.2.0 + growth log.
2026-06-21 21:50:32 +02:00
agent 3c9e909eda feat(infra): detect_fleet_context — contexto de flota por $TMUX (no $FLEET_SOCKET)
Funcion nueva detect_fleet_context_bash_infra (tag orchestration). Deriva
socket/session de $TMUX (senal fiable que todo proceso dentro de tmux tiene
siempre), con fallback a $FLEET_SOCKET/$FLEET_SESSION. Devuelve JSON
{in_fleet,in_tmux,socket,session,source}. Causa raiz del bug: $FLEET_SOCKET
(exportada con tmux set-environment -g por launch_fleetclaude) a veces viene
vacia en un claude resumido/relanzado pese a vivir en la flota, y el modo
orquestador caia al fallback kitty. .md self-doc (Ejemplo + Cuando usarla +
Gotchas).
2026-06-21 21:50:32 +02:00
egutierrez 3cf8b21fea feat(datascience): promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d)
Completa la promoción del flujo imagen->3D al registry (grupo de capacidad
img-to-3d), extraído de la app img_to_3d_webapp.

- remove_background_py_datascience (nueva): elimina el fondo con cascada
  rembg/U2Net -> OpenCV GrabCut -> umbral NumPy, compone el objeto sobre gris
  neutro y devuelve image + mask + engine. Impura, nunca lanza. Adaptada de
  backend/bg_removal.py con firma de ruta (image_path) y salida dict, demo CLI
  JSON-serializable.
- depth_to_relief_glb_py_datascience (v1.1.0): añade el parámetro opcional mask
  para recortar la malla de relieve al objeto (descarta las caras del fondo),
  cerrando la cadena con remove_background. Aditivo (mask=None = comportamiento
  previo), fiel al original de backend/depth.py.
- docs/capabilities/img-to-3d.md: incorpora remove_background como paso 0
  (pre-proceso), actualiza el flujo a 3 pasos encadenados, la tabla de funciones
  (4), el ejemplo end-to-end con mask y las deps (rembg/opencv).
- docs/capabilities/INDEX.md: conteo del grupo 3 -> 4.

Las dos funciones ya presentes (estimate_image_depth, depth_to_relief_glb) y el
pipeline build_relief_glb_from_image fueron promovidas en una ronda previa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:43:08 +02:00
egutierrez cbefc82c02 Merge orq/pane-id-json: campo ClaudeFleet.PaneID + resolve_pane_ids + poblar en list_claude_fleet (report 0039) 2026-06-21 21:30:40 +02:00
157 changed files with 13891 additions and 80 deletions
+112
View File
@@ -0,0 +1,112 @@
---
description: Modo ausente — el orquestador itera solo (lanza agentes, verifica cierres, genera tareas del roadmap, push periódico) sin supervisión, hasta que el humano vuelva. Auto-continúa con ScheduleWakeup.
---
# /ausente — orquestador autónomo desatendido
Activa un **loop autónomo del modo orquestador**: el humano se va y tú sigues trabajando solo
—lanzando agentes, verificando sus cierres, cerrando los que cumplen su DoD, generando tareas
nuevas cuando la flota se vacía, y sincronizando— **hasta que el humano vuelva**. Es el modo
orquestador (`.claude/commands/orquestador.md` + `.claude/rules/orchestration.md`) corriendo sin
prompts humanos, con un mecanismo de auto-continuación.
Requisito: estar ya en modo orquestador (`role=orchestrator`). `/ausente` NO sustituye al
orquestador, lo deja en piloto automático.
## Configuración de esta sesión (elegida por el humano)
- **Al vaciarse la flota**: seguir el **roadmap ComfyUI** — generar tareas nuevas sin parar.
- **Git**: **push periódico**`/full-git-push` tras cada bloque de tareas cerrado.
- **Límite**: **hasta que el humano vuelva** — heartbeat ~25 min + el watcher; tope DURO de 6
ejecutores a la vez; parar en cuanto el humano escriba.
(Si se reinvoca `/ausente` en otra sesión, re-confirmar estas 3 con el humano vía AskUserQuestion.)
## El bucle (cada vez que te re-invocan: por FLEET-DONE del watcher o por el heartbeat)
1. **Drena la flota**: `./fn run drain_fleet_events`. Para cada ejecutor `DICE_TERMINADO`:
**verifica de primera mano** (lee su report + comprueba en disco/CDP que el golden existe — no
te fíes del autodeclarado). Si cumple el DoD → `set_dod_contract <sid> "<c>" met` y **ciérralo
con `kill <PID>` directo** (NUNCA `kill_fleet_agent`/`kill-window`: cierra windows ajenas y se
llevó la console de fleetview — incidente real). Si falla → nudge con el gap concreto.
2. **Nudge** a los `ESTANCADO` (idle > 10 min con DoD sin cerrar). NUNCA a `waiting`.
3. **¿Flota con hueco?** (< 6 ejecutores y hay backlog) → **genera la siguiente tarea del roadmap**
(lista abajo), escribe su prompt autocontenido con aislamiento + DoD-contrato, lánzala con
`spawn_fleet_agent --parent <tu-sid>`, fíjale nombre (`fleet_set_name`) + DoD. Respeta el tope
de 6 y la disjunción de recursos (server/venv/GPU vs functions+fn_index vs disco — ver
`orchestration.md`): solo UN agente dueño del server/venv a la vez; solo UNO toca
`functions/`+`fn index` a la vez; los descargadores de modelos van a carpetas distintas.
4. **Push periódico**: cuando cierres un bloque (>=1 tarea met e integrada), corre
`./fn run full_git_push_bash_pipelines ""` y verifica que el padre queda alineado con
`origin/master`. Diagnostica y reintenta si falla (regla de `/full-git-push`).
5. **Bitácora**: añade una línea al report de bitácora `reports/NNNN-ausente-bitacora.md` (créalo
la primera vez): timestamp + qué cerraste + qué lanzaste + push. Es lo que el humano lee al
volver.
6. **Reprograma el heartbeat**: `ScheduleWakeup(delaySeconds≈1500, prompt="/ausente",
reason="loop ausente: vigilar flota + roadmap ComfyUI")`. Si hay agentes en vuelo, el watcher
te empujará sus FLEET-DONE antes (no hace falta wakeup corto); el heartbeat es el fallback para
cuando la flota está vacía y hay que generar tareas nuevas.
## Supervivencia a la compactación de contexto
El loop es de larga duración → el contexto se llenará. **Cuando te quedes sin contexto, deja que
el harness compacte la conversación y CONTINÚA el modo ausente** — no lo trates como una parada.
El modo sobrevive porque su estado es **durable fuera del contexto**:
- El `ScheduleWakeup(prompt="/ausente")` re-inyecta el modo en cada heartbeat (y el FLEET-DONE del
watcher también te re-entra).
- La **bitácora** `reports/ausente-bitacora-2026-06-24.md` es la memoria persistente: qué se cerró,
qué se lanzó, qué falta del backlog, último push. **Tras una compactación, lo PRIMERO es releer
la bitácora** (y `fleet_list`) para reconstruir el estado y seguir donde lo dejaste.
- Mantén la bitácora al día en CADA turno (no solo al cerrar bloques) para que la compactación
nunca pierda progreso. El comando `/ausente` + `orchestration.md` reconstruyen la doctrina.
Una compactación NO es el humano volviendo — sigue iterando con normalidad.
## Parada
- **El humano vuelve** = recibes un prompt que NO es un FLEET-DONE ni el `/ausente` del heartbeat
(es texto del humano). Entonces: **no reprogrames el wakeup**, resume todo lo hecho durante la
ausencia (lee la bitácora) y vuelve al modo orquestador interactivo normal.
- Si el backlog del roadmap se agota del todo (raro): haz un último push, deja la flota cerrada,
escribe el resumen en la bitácora, programa un heartbeat largo y queda a la espera.
## Reglas duras (más estrictas sin supervisión)
- **Nada destructivo ni irreversible sin el humano**: no borrar datos/modelos/repos, no `git push
--force`, no tocar producción/VPS, no mandar nada hacia afuera (correos, mensajes, APIs con
efecto), no pagar/descargar gated de pago. Ante la duda, NO lo hagas: déjalo anotado en la
bitácora como "pendiente de revisión humana".
- **Aislamiento git por agente** SIEMPRE (sub-repo / worktree / scope disjunto). Ningún agente
commitea el padre salvo el push periódico que corres tú.
- **Tope 6 ejecutores**. Encola el resto.
- **Cierre por `kill <PID>`**, jamás `pkill`/`killall`/`kill_fleet_agent` (protege la TUI/console
de fleetview y a ti mismo).
- **Verificación adversarial**: el golden de cada cierre se comprueba en disco/CDP/ejecución, no
por lo que el agente diga. Honestidad en la bitácora (gaps incluidos).
- Cada agente full-capaz sigue registry-first y delega a `fn-constructor`; tú no escribes lógica
reutilizable inline.
## Backlog del roadmap ComfyUI (fuente de tareas a generar; prioriza arriba→abajo)
Base: `reports/0064-comfyui-roadmap-plan.md` + propuestas de los reports 0069/0073/0075/0079.
1. **Funciones 3D propuestas pendientes**: `comfyui_build_view_3d_workflow`,
`comfyui_generate_views_from_image` (Zero123/SV3D), `comfyui_text_to_3d_oneshot` (pipeline),
`comfyui_build_multiview_textured_3d_workflow`. (Dueño de functions/+fn index, uno a la vez.)
2. **`comfyui_download_workflow`** (detecta Drive/GitHub/Civitai/PNG → API format) — del catálogo
de fuentes (report `comfyui-wf-sources`).
3. **P2 del roadmap**: `comfyui_batch_generate`, `comfyui_interrupt_queue`,
`comfyui_ensure_server` (systemd-user con --lowvram + health).
4. **Vídeo end-to-end**: montar workflow LTX-Video y Wan2.1 (modelos ya en /mnt/2tb), generar un
clip corto SFW de prueba, validar VRAM 8GB; capitalizar `comfyui_build_video_workflow`.
5. **Calidad 3D**: decimación de mesh (`fast_simplification`, gap del 0069) + watertight
(`VoxelToMesh`); función `comfyui_simplify_mesh`.
6. **Librería de workflows**: bajar+validar los ejemplos recomendados por `comfyui-wf-sources`,
dejarlos en una librería local validada contra nuestro server.
7. **Higiene**: `fn doctor` sobre las funciones nuevas (uses-functions/unused), capability page
`docs/capabilities/comfyui.md` al día, tests de las funciones sin cobertura.
8. Cuando ideas concretas se agoten: un agente "completeness critic" que audite el grupo `comfyui`
y proponga el siguiente lote.
Cada tarea generada respeta el patrón del orquestador: prompt autocontenido (objetivo, dir,
aislamiento, qué entrega, DoD-contrato golden+edge+error), `--parent`, nombre + DoD fijados al
lanzar, verificación de primera mano al cerrar.
## Relación
- `.claude/commands/orquestador.md` — el modo base; `/ausente` es su versión desatendida.
- `.claude/rules/orchestration.md` — maquinaria (drain, clasificación, verificador, nudge, tope).
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox). `/ausente` NO es
eso: aquí TÚ (el orquestador interactivo) sigues conduciendo la flota de Claudes interactivos.
+81
View File
@@ -0,0 +1,81 @@
---
description: "Espejo de requisitos: Claude reformula con detalle la última tarea pedida (objetivo, alcance, entregables, supuestos, criterios de aceptación, fuera de alcance y dudas) para confirmar alineación antes de ejecutar. No ejecuta nada."
argument-hint: "[opcional: matiz o foco a tener en cuenta al reformular]"
---
# /equal — confirmar alineación reformulando la tarea pedida
Mecanismo de **espejo de requisitos**. Cuando el usuario invoca `/equal`, NO ejecutas la tarea: devuelves tu interpretación detallada y estructurada del encargo más reciente, para que el usuario confirme o corrija antes de que empieces a trabajar.
El objetivo es eliminar el malentendido silencioso: prefieres gastar un turno reflejando lo que crees que se te pide que arrancar en la dirección equivocada.
## Qué hacer al invocarse
1. **Identifica la tarea más reciente que el usuario te ha pedido** en la conversación actual: la última petición de trabajo real, no el `/equal` en sí ni un comando de utilidad anterior. Si hay `$ARGUMENTS`, úsalos como matiz o foco adicional al reformular (p. ej. "céntrate en el alcance" o "asume que es solo el backend"), no como la tarea nueva.
2. **Reformula esa tarea de forma detallada y estructurada**, con estas secciones (omite una sección solo si es genuinamente no aplicable, no para abreviar):
- **Objetivo** — qué se quiere conseguir, en una o dos frases claras. El "para qué", no solo el "qué".
- **Alcance / qué incluye** — los trozos concretos de trabajo que entiendes incluidos. Lista, no párrafo.
- **Entregables** — qué archivos, cambios, salidas o artefactos concretos vas a producir.
- **Supuestos** — lo que estás asumiendo por defecto al no estar dicho explícitamente (stack, ubicación, convenciones, datos, alcance temporal). Hazlos visibles para que el usuario los pueda tumbar.
- **Criterios de aceptación** — cómo sabremos que está bien hecho. Condiciones verificables, no deseos vagos. Cuando aplique, golden + edge + caso de error (alineado con `dod_quality.md`).
- **Fuera de alcance** — lo que NO vas a hacer, para acotar expectativas y evitar scope creep.
- **Dudas / ambigüedades a confirmar** — preguntas concretas sobre lo que no está claro. Numéralas para que el usuario pueda responder por número. Si no hay dudas reales, dilo explícitamente ("sin dudas bloqueantes").
3. **Cierra pidiendo validación**: una línea clara del tipo "¿Alineado? Corrige lo que no cuadre y arranco." No empieces a trabajar hasta que el usuario confirme.
## Caso sin tarea previa
Si **no hay** una petición de trabajo clara que reformular (la conversación está vacía de encargos, o lo último fueron solo comandos de utilidad / charla), NO inventes una tarea. Dilo directamente y pide al usuario que describa qué quiere hacer, ofreciéndole la misma estructura como guía de qué información es útil (objetivo, entregables, criterios).
## Reglas duras
- **No ejecutas la tarea.** `/equal` solo refleja y pregunta. Nada de editar archivos, lanzar comandos de trabajo ni delegar a subagentes para "ir avanzando".
- **No inventes requisitos.** Lo que no se ha pedido va a *Supuestos* (marcado como asunción) o a *Dudas* (como pregunta), nunca colado como hecho.
- **Refleja lo que se pidió, no lo que crees que debería pedirse.** Si detectas una mejora obvia, propónla como una duda al final, no la incorpores al objetivo.
- **Sé concreto.** Evita reformulaciones genéricas que valdrían para cualquier tarea. Cita los detalles reales del encargo (nombres de archivos, rutas, tecnologías, cifras que el usuario mencionó).
- Mantén el comando **simple**: es un reformulador de requisitos, no un planificador de implementación ni un generador de tareas.
## Ejemplo de salida
Para una petición previa como *"añade un endpoint `/health` al servicio de deploy que devuelva 200 y la versión"*:
```
## Objetivo
Añadir un health check HTTP al deploy_server para poder verificar de forma
programática que el servicio está vivo y saber qué versión corre.
## Alcance / qué incluye
- Nuevo handler GET `/health` en el servidor HTTP de deploy_server.
- Respuesta 200 con un cuerpo JSON que incluya la versión del binario.
- Registro de la ruta en el router existente.
## Entregables
- Cambio en el código del servidor de `apps/deploy_server/` (handler + ruta).
- Sin migración de BD (no toca schema).
## Supuestos
- La versión a devolver es la que ya expone el binario (no hay que crear un
sistema de versionado nuevo).
- Formato de respuesta JSON `{"status":"ok","version":"x.y.z"}`.
- Sin autenticación en `/health` (endpoint público de liveness).
## Criterios de aceptación
- Golden: `GET /health` con el servicio arriba → 200 + JSON con `version`.
- Edge: la versión se lee correctamente aunque el binario se compile sin tag.
- Error: si un subsistema crítico no está listo, devuelve 503, no 200 falso.
## Fuera de alcance
- Readiness check de dependencias remotas (VPS, SSH).
- Métricas / observabilidad más allá del 200.
## Dudas / ambigüedades a confirmar
1. ¿`/health` debe comprobar algo real (DB, disco) o basta con responder vivo?
2. ¿La versión sale de un ldflag de build, de un fichero, o de constante?
3. ¿Puerto y router son los que ya usa `deploy_server serve`?
¿Alineado? Corrige lo que no cuadre y arranco.
```
El ejemplo es ilustrativo del **formato y el nivel de detalle**; el contenido real sale siempre de la tarea concreta que el usuario haya pedido en la conversación.
+43 -19
View File
@@ -75,36 +75,59 @@ siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/or
### 2. Lanzar cada secundario
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
hay perfil fleet (`$FLEET_SOCKET`, lo normal), o kitty fuera de él. NUNCA como sub-agente del Agent
tool (ver paso 8).** Empieza por el bloque de flota tmux cuando estás en un perfil fleet; kitty es
el fallback para secundarios que deban vivir fuera de la flota.
estás dentro de tmux/una flota, o kitty SOLO cuando de verdad no hay tmux. NUNCA como sub-agente del
Agent tool (ver paso 8).** La detección de "estoy en una flota" se hace por **`$TMUX`** (señal
fiable, vía `detect_fleet_context`), **NO por `$FLEET_SOCKET`** (a veces viene vacía en un claude
resumido/relanzado pese a vivir en la flota → te haría caer a kitty por error). El hook
`hook_fleet_state_inject.sh` te inyecta cada turno una línea `CONTEXTO FLEET: … socket=<X>` cuando
estás dentro de la flota; úsala. Empieza por el bloque de flota tmux; kitty es el fallback solo fuera
de tmux.
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
#### En la flota tmux (PREFERIDO en perfil fleet)
**Nombra cada secundario para diferenciarlo de un vistazo (regla dura).** Cuando lances varios a la
vez, el humano tiene que poder distinguirlos rápido en el sidebar de fleetview. Dos cosas:
Si estás dentro de un perfil FleetView (`$FLEET_SOCKET` seteada), **NO lances kitties sueltas**:
lanza cada ejecutor como una **window de la flota tmux** con `spawn_fleet_agent`, para que viva en
la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
1. **`--title` descriptivo y prefijado** en cada `spawn_fleet_agent`: un slug corto y único que diga
QUÉ hace ese agente, idealmente con una letra/índice para ordenarlos (`A·mcp-rename`,
`B·sql-navision`, `C·kanban`, `D·equal-skill`). Esto nombra la window tmux.
2. **El nombre del sidebar fleetview = el campo `goal`** del `~/.claude/goals/<sid>.json`. En cuanto
resuelvas el `sessionId` del secundario, fíjale un nombre claro con la tool
`mcp__orchestrator__fleet_set_name` (o `./fn run set_fleet_name` cuando exista el fallback CLI) —
mismo slug descriptivo que el `--title`. Si esa capacidad aún no está disponible en la sesión,
apóyate solo en `--title` y en que el `goal` autogenerado del prompt sea descriptivo, pero el
objetivo es que el sidebar liste nombres legibles, no objetivos genéricos repetidos.
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
con `$FLEET_SOCKET`), **NO lances kitties sueltas**: lanza cada ejecutor como una **window de la
flota tmux** con `spawn_fleet_agent`, para que viva en la flota, se vea en la TUI `fleetview` y sea
conmutable con `/fleet focus`:
```bash
./fn run spawn_fleet_agent --socket "$FLEET_SOCKET" --session "$FLEET_SESSION" \
# spawn_fleet_agent auto-detecta el socket/session de $TMUX — NO hace falta pasar --socket/--session:
./fn run spawn_fleet_agent \
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
--parent "$MI_SESSION_ID"
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
```
- `spawn_fleet_agent_bash_infra` crea la window tmux + arranca claude con el prompt autocontenido
(o `--skill <name>`), y con `--role executor|orchestrator` marca su `goal.json`. El aislamiento
git (sub-repo / worktree / scope) sigue imponiéndose en el prompt.
- `spawn_fleet_agent_bash_infra` **auto-detecta** socket/session del contexto tmux (`$TMUX`) vía
`detect_fleet_context`; pásalos explícitos solo si quieres otra flota (los explícitos priman).
Crea la window tmux + arranca claude con el prompt autocontenido (o `--skill <name>`), y con
`--role executor|orchestrator` marca su `goal.json`. El aislamiento git (sub-repo / worktree /
scope) sigue imponiéndose en el prompt.
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
retro-compatible. Ver `.claude/rules/orchestration.md`.
#### Fuera de la flota (kitty fallback)
#### Fuera de tmux (kitty fallback)
Solo cuando `detect_fleet_context` reporta `in_tmux=false` (de verdad no hay tmux):
```bash
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
@@ -113,7 +136,8 @@ la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` lanza el secundario con el
comando canónico (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo fuera de un perfil fleet.
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo cuando NO estás en tmux
(`$TMUX` vacía); estando en una flota, kitty fragmenta la flota — usa `spawn_fleet_agent`.
### 3. Aislamiento git obligatorio por secundario (regla de oro)
@@ -204,8 +228,8 @@ Cuando un secundario termina (rama pusheada + report verde):
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
1. **En perfil fleet** (`$FLEET_SOCKET`, lo normal) → `spawn_fleet_agent` (window de la flota tmux).
2. **Fuera de un perfil fleet** → kitty con `launch_claude_agent_kitty`.
1. **Dentro de tmux/flota** (`$TMUX` seteada — comprueba con `detect_fleet_context`, NO con `$FLEET_SOCKET`) → `spawn_fleet_agent` (auto-detecta el socket; window de la flota tmux).
2. **Fuera de tmux** (`in_tmux=false`) → kitty con `launch_claude_agent_kitty`.
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
@@ -268,10 +292,10 @@ git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
# 4. Lanzar ambos (window de la flota si hay $FLEET_SOCKET; aquí kitty fallback). Tras conocer su
# sessionId, escribe su DoD-contrato con set_dod_contract.
./fn run launch_claude_agent_kitty "kanban · health endpoint" ~/fn_registry/apps/kanban /tmp/orq_health.md
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" /tmp/orq_capdoc /tmp/orq_capdoc.md
# 4. Lanzar ambos como windows de la flota (estás en tmux → spawn_fleet_agent auto-detecta el socket
# de $TMUX; kitty SOLO si in_tmux=false). Tras conocer su sessionId, escribe su DoD-contrato.
./fn run spawn_fleet_agent --cwd ~/fn_registry/apps/kanban --prompt-file /tmp/orq_health.md --title "kanban · health endpoint" --parent "$MI_SESSION_ID"
./fn run spawn_fleet_agent --cwd /tmp/orq_capdoc --prompt-file /tmp/orq_capdoc.md --title "fn_registry · doc deploy" --parent "$MI_SESSION_ID"
# 5. Seguir cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
+29 -4
View File
@@ -27,7 +27,7 @@ La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
```bash
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, pane_id ("%N", el id estable), tmux_window ("@N", interno para focus/send-keys), age, idle_seconds
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
```
@@ -58,7 +58,7 @@ devuelven salida estructurada y se registran en la telemetría como cualquier MC
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|---|---|---|
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, **pane_id** (el id estable), age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
@@ -69,6 +69,15 @@ Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directa
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
el sidecar a mano — filtra por el `role` que ya trae cada fila.
**Identifica a cada agente por su `pane_id` ("%N").** Es el id ESTABLE de por vida del pane: el
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
necesitan la window viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada);
para el nudge, lee `tmux_window` del binario `fleetview list --json` (que sí lo conserva como campo
interno), nunca del payload del MCP.
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
@@ -123,6 +132,21 @@ existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo)
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
sobre el feed del watcher.
Además, el mismo hook inyecta una línea **`CONTEXTO FLEET`** cuando detecta (vía
`detect_fleet_context_bash_infra`, leyendo **`$TMUX`**, no `$FLEET_SOCKET`) que el orquestador vive
dentro de una flota tmux:
```
CONTEXTO FLEET: estás dentro de la fleet tmux socket=<X> session=<Y>. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aquí.
```
Es el recordatorio que evita el bug de caer a kitty cuando `$FLEET_SOCKET` viene vacía pese a estar
en la flota: la detección de contexto se hace por `$TMUX` (señal fiable que todo proceso dentro de
tmux tiene siempre), no por `$FLEET_SOCKET` (a veces ausente en un claude resumido/relanzado). Esta
parte del hook no necesita venv ni python (solo bash + tmux) y se emite antes del bloque
`FLEET-STATE`; si el detector falta o `$TMUX` está vacía, simplemente no se emite la línea (turno
intacto).
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
@@ -302,7 +326,8 @@ en lote.
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty cuando hay perfil fleet. `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `detect_fleet_context_bash_infra` | Detectar si estás en una flota tmux derivando socket/session de `$TMUX` (señal fiable), con fallback a `$FLEET_SOCKET`. Devuelve JSON `{in_fleet,in_tmux,socket,session,source}`. Lo usan `spawn_fleet_agent` (auto-detección de socket) y el hook (línea `CONTEXTO FLEET`) para no caer a kitty estando en la flota |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty siempre que estés en tmux. **Auto-detecta socket/session de `$TMUX`** (vía `detect_fleet_context`) si no se pasan `--socket`/`--session` (los explícitos priman). `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
@@ -311,7 +336,7 @@ en lote.
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
`spawn_fleet_agent`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`spawn_fleet_agent`, `detect_fleet_context`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
mano).
@@ -46,6 +46,24 @@ ROLE=""
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
# Contexto de flota: recordarle al orquestador en que socket/sesion tmux vive,
# para que lance ejecutores con spawn_fleet_agent (auto-detecta el socket) y
# NUNCA caiga a kitty estando dentro de la flota. La deteccion va por $TMUX
# (senal fiable), no por $FLEET_SOCKET (a veces vacia en un claude resumido/
# relanzado). No necesita venv ni python: solo bash + tmux. Degrada limpio: si
# el detector falta o falla, simplemente no se emite la linea (turno intacto).
DETECTOR="$PROJECT_DIR/bash/functions/infra/detect_fleet_context.sh"
if [ -f "$DETECTOR" ]; then
CTX=$(bash "$DETECTOR" 2>/dev/null || true)
IN_FLEET=$(printf '%s' "$CTX" | sed -n 's/.*"in_fleet":\(true\|false\).*/\1/p')
F_SOCKET=$(printf '%s' "$CTX" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')
F_SESSION=$(printf '%s' "$CTX" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')
if [ "$IN_FLEET" = "true" ]; then
printf 'CONTEXTO FLEET: estas dentro de la fleet tmux socket=%s session=%s. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aqui.\n' "$F_SOCKET" "$F_SESSION"
fi
fi
PY="$PROJECT_DIR/python/.venv/bin/python3"
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
+2 -1
View File
@@ -8,7 +8,8 @@
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
"jupyter",
"orchestrator"
],
"hooks": {
"PreToolUse": [
+1
View File
@@ -37,6 +37,7 @@ python/.venv/
# Externalized apps and analysis (each is its own Gitea repo)
apps/*/
cpp/apps/*/
analysis/*/
# Projects (each is its own git repo, only project.md templates are versioned)
@@ -0,0 +1,98 @@
---
name: detect_fleet_context
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "detect_fleet_context() -> JSON {in_fleet,in_tmux,socket,session,source}"
description: "Detecta de forma robusta si el proceso corre dentro de una flota tmux FleetView, derivando socket y sesion de $TMUX (senal fiable) en vez de $FLEET_SOCKET (fragil, a veces vacia en un claude resumido/relanzado). Salida JSON con in_fleet/in_tmux/socket/session/source."
tags: [orchestration, fleet, tmux, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
file_path: "bash/functions/infra/detect_fleet_context.sh"
params:
- name: "(ninguno)"
desc: "No recibe argumentos. Lee el entorno ($TMUX, con fallback a $FLEET_SOCKET/$FLEET_SESSION) y consulta el servidor tmux."
output: "JSON en stdout: {\"in_fleet\":bool, \"in_tmux\":bool, \"socket\":str, \"session\":str, \"source\":\"tmux|fleet_socket|none\"}. in_tmux=true basta para lanzar una window; in_fleet es la senal semantica de 'estoy en una flota'."
---
# detect_fleet_context
Detecta el contexto de flota del proceso actual sin depender de `$FLEET_SOCKET`.
## Por que existe
La deteccion de "estoy en una flota FleetView" dependia de la variable de
entorno `$FLEET_SOCKET`, que `launch_fleetclaude` exporta con
`tmux set-environment -g`. Esa variable solo llega a los procesos que tmux
arranca **despues** de setearla: un `claude` relanzado o resumido a mano puede
no heredarla y `$FLEET_SOCKET` queda vacia, aunque ese claude SI viva en una
window de la flota. Cuando eso pasa, el modo orquestador cae al fallback kitty
(`launch_claude_agent_kitty`) y lanza ejecutores en terminales sueltas en vez de
como windows de la flota.
La senal **fiable** es `$TMUX`: todo proceso dentro de tmux la tiene SIEMPRE, con
el formato `/tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>`. De ahi se extrae
el socket (basename del path antes de la primera coma) y, con
`tmux -L <socket> display-message -p '#{session_name}'`, la sesion actual.
## Salida
```json
{"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
```
| Campo | Significado |
|---|---|
| `in_fleet` | Heuristica de "estoy en una flota". `true` si en tmux Y (socket/sesion casan `fleet`, O hay window `fleetview`, O la sesion tiene >= 2 windows). |
| `in_tmux` | `true` si el proceso esta dentro de tmux. Basta para lanzar una window (mejor que caer a kitty). |
| `socket` | Socket tmux derivado de `$TMUX` (o de `$FLEET_SOCKET` en fallback). |
| `session` | Sesion tmux actual resuelta con `display-message` (fallback a `$FLEET_SESSION` o al socket). |
| `source` | `tmux` (derivado de `$TMUX`), `fleet_socket` (fallback), o `none`. |
## Ejemplo
```bash
# Dentro de una window de la flota fleet3:
bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
# Fuera de tmux, sin FLEET_SOCKET:
env -u TMUX -u FLEET_SOCKET bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":false,"in_tmux":false,"socket":"","session":"","source":"none"}
# Parsear el socket con jq para pasarlo a spawn_fleet_agent:
ctx=$(bash bash/functions/infra/detect_fleet_context.sh)
sock=$(printf '%s' "$ctx" | jq -r .socket)
```
## Cuando usarla
Antes de lanzar un ejecutor de la flota: llama a esta funcion para saber si
estas dentro de una flota tmux. Si `in_tmux=true`, lanza con `spawn_fleet_agent`
(que ya la usa para auto-detectar el socket); NUNCA caigas a kitty. Tambien la
usa el hook `hook_fleet_state_inject.sh` para recordarle al orquestador el socket
de su flota cada turno.
## Gotchas
- Es **impura**: consulta el servidor tmux (`display-message`, `list-windows`).
No modifica estado.
- `in_fleet` es **heuristico** a proposito. Para LANZAR basta `in_tmux=true`
(lanzar una window en cualquier tmux supera a una kitty suelta). `in_fleet` es
solo la senal semantica que consume el hook y la doctrina.
- Fallback `source=fleet_socket`: si `$TMUX` no esta pero `$FLEET_SOCKET` si,
devuelve `socket`/`session` de esas vars con `in_tmux=false`. Un
`tmux -L <socket> new-window` puede seguir funcionando si el servidor existe,
aunque el caller no este attached.
- No requiere `jq` ni python: emite el JSON con `printf`, para poder ser el
detector base que invocan hooks y otras funciones bash.
- Si `tmux` no esta instalado y `$TMUX` esta seteada (raro), `socket` se deriva
igual de `$TMUX` pero `session` cae al fallback y `in_fleet` no se puede afinar
por windows.
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# detect_fleet_context — detecta de forma robusta si el proceso actual corre
# dentro de una sesion tmux de una flota FleetView, derivando el socket y la
# sesion de la variable de entorno $TMUX (senal fiable) en vez de depender de
# $FLEET_SOCKET (que a veces viene vacia en el entorno de un claude resumido o
# relanzado, aunque ese claude SI viva en una window de la flota).
#
# Por que $TMUX y no $FLEET_SOCKET:
# launch_fleetclaude exporta FLEET_SOCKET/FLEET_SESSION con `tmux
# set-environment -g`. Esa variable solo llega a los procesos que tmux arranca
# DESPUES de setearla; un claude relanzado o resumido a mano puede no heredarla
# y entonces $FLEET_SOCKET queda vacia. En cambio, todo proceso que corre
# dentro de tmux tiene SIEMPRE $TMUX seteada, con el formato:
# /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# De ahi se extrae el socket (basename del path antes de la primera coma) y,
# con `tmux -L <socket> display-message -p '#{session_name}'`, la sesion
# actual. Eso identifica el contexto fleet sin depender de $FLEET_SOCKET.
#
# Salida: JSON en stdout con los campos:
# in_fleet : true|false — heuristica de "estoy en una flota" (ver criterio).
# in_tmux : true|false — estoy dentro de tmux (basta para lanzar una window).
# socket : nombre del socket tmux derivado ("" si no hay).
# session : nombre de la sesion tmux actual ("" si no se resuelve).
# source : "tmux" | "fleet_socket" | "none" — de donde se derivo el contexto.
#
# Criterio de "flota reconocible" (in_fleet): estar en tmux (in_tmux) Y que se
# cumpla al menos uno, de mas fiable a menos:
# 1. el socket o la sesion casan el patron de flota (contienen "fleet"), o
# 2. existe una window llamada "fleetview" (la TUI de la flota), o
# 3. la sesion tiene >= 2 windows (una flota agrupa varios agentes en windows).
# Es heuristico a proposito: para LANZAR un ejecutor basta con in_tmux (lanzar
# una window en cualquier tmux es mejor que caer a una kitty suelta); in_fleet es
# la senal semantica que consume el hook del orquestador y la doctrina.
#
# Funcion IMPURA: lee el entorno y consulta el servidor tmux (display-message,
# list-windows). No modifica estado. Degrada limpio: si tmux no esta o falla
# cualquier consulta, devuelve los campos que pueda y nunca aborta con error.
set -euo pipefail
IFS=$' \t\n'
detect_fleet_context() {
local socket="" session="" source="none"
local in_tmux="false" in_fleet="false"
if [[ -n "${TMUX:-}" ]]; then
in_tmux="true"
source="tmux"
# $TMUX = /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# Socket = basename del path antes de la primera coma.
local tmux_path="${TMUX%%,*}"
socket="$(basename "$tmux_path" 2>/dev/null || true)"
# Sesion actual: tmux resuelve el cliente via $TMUX. -L fija el socket.
if command -v tmux >/dev/null 2>&1 && [[ -n "$socket" ]]; then
session="$(tmux -L "$socket" display-message -p '#{session_name}' 2>/dev/null || true)"
fi
# Fallback de sesion si display-message no resolvio nada.
[[ -z "$session" ]] && session="${FLEET_SESSION:-$socket}"
elif [[ -n "${FLEET_SOCKET:-}" ]]; then
# No estamos en tmux pero hay FLEET_SOCKET exportada: usarla como ultimo
# recurso (un claude que perdio $TMUX pero conserva la env del perfil).
in_tmux="false"
source="fleet_socket"
socket="${FLEET_SOCKET}"
session="${FLEET_SESSION:-$socket}"
fi
# Heuristica in_fleet: solo tiene sentido si estamos en tmux.
if [[ "$in_tmux" == "true" && -n "$socket" ]]; then
local sl="${socket,,}" sesl="${session,,}"
if [[ "$sl" == *fleet* || "$sesl" == *fleet* ]]; then
in_fleet="true"
elif command -v tmux >/dev/null 2>&1; then
# Construir el target de sesion sin trucos de expansion fragiles.
local -a tgt=()
[[ -n "$session" ]] && tgt=(-t "$session")
# window "fleetview" presente => flota.
if tmux -L "$socket" list-windows "${tgt[@]}" \
-F '#{window_name}' 2>/dev/null | grep -qx 'fleetview'; then
in_fleet="true"
else
# >= 2 windows => agrupacion tipo flota.
local nwin
nwin="$(tmux -L "$socket" list-windows "${tgt[@]}" \
-F x 2>/dev/null | wc -l | tr -d ' ')"
[[ "${nwin:-0}" -ge 2 ]] && in_fleet="true"
fi
fi
fi
# JSON sin dependencias (jq/python no requeridos: este es el detector base).
printf '{"in_fleet":%s,"in_tmux":%s,"socket":"%s","session":"%s","source":"%s"}\n' \
"$in_fleet" "$in_tmux" "$socket" "$session" "$source"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
detect_fleet_context "$@"
fi
+16 -5
View File
@@ -3,23 +3,24 @@ name: spawn_fleet_agent
kind: function
lang: bash
domain: infra
version: 1.1.0
version: 1.2.0
purity: impure
signature: "spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
signature: "spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. --socket/--session son opcionales: si no se pasan se auto-detectan del contexto tmux ($TMUX) via detect_fleet_context (los explicitos tienen prioridad), evitando caer a kitty cuando $FLEET_SOCKET viene vacia. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
tags: [fleet, claude-fleet, orchestration, tmux, infra]
uses_functions:
- mark_claude_role_py_infra
- mark_claude_parent_py_infra
- detect_fleet_context_bash_infra
uses_types: []
error_type: error_go_core
file_path: "bash/functions/infra/spawn_fleet_agent.sh"
tested: false
params:
- name: --socket
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). El perfil debe estar ya montado (sesion viva)."
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). Opcional: se auto-detecta de $TMUX via detect_fleet_context si no se pasa. El perfil debe estar ya montado (sesion viva)."
- name: --session
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket)."
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket). Opcional: se auto-detecta de $TMUX si no se pasa."
- name: --cwd
desc: "Directorio de trabajo del nuevo Claude. Default: PWD."
- name: --prompt-file
@@ -54,6 +55,11 @@ Lanza un Claude dentro de un perfil FleetView (sesion tmux de un socket aislado)
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health" \
--parent 32945650-a4e1-472b-90c9-5b38ef60a463
# Sin --socket/--session: auto-detecta el socket de la flota actual ($TMUX).
# Forma preferida desde dentro de la flota — no hace falta saber el socket:
./fn run spawn_fleet_agent --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health"
```
## Cuando usarla
@@ -62,9 +68,14 @@ Cuando el orquestador (o el launcher) necesita arrancar un Claude que debe vivir
## Gotchas
- **Auto-deteccion de socket/session**: si no pasas `--socket`/`--session`, se derivan de `$TMUX` via `detect_fleet_context`. Los explicitos tienen prioridad. Solo aborta (exit 2) si tras auto-detectar siguen vacios (de verdad no hay tmux). No dependas de `$FLEET_SOCKET`: a veces viene vacia en un claude resumido/relanzado aunque viva en la flota — `$TMUX` es la senal fiable.
- El perfil (socket+session) debe estar **ya montado** (`launch_fleetclaude` primero); si la sesion no existe, falla con exit 1.
- El `--role` se aplica en **background**: el `sessionId` del nuevo Claude no existe hasta que Claude escribe `~/.claude/sessions/<PID>.json` (unos segundos). `mark_claude_role` espera ese archivo. Si el arranque es muy lento, el role puede tardar en aparecer; es no-fatal (el agente simplemente no se pinea/identifica hasta entonces).
- El `--parent` se aplica igual en **background** via `mark_claude_parent` (misma espera del `sessions/<PID>.json`). Cuando se pasan `--role` y `--parent` juntos se encadenan **secuencialmente** en el mismo subshell (primero role, luego parent) para que la segunda escritura lea el goal ya con la primera clave puesta — sin carrera de lectura-modificacion-escritura. Es no-fatal: si el sessions JSON no aparece a tiempo, el `parent_orchestrator` simplemente no se escribe.
- `--skill` envia `/<name>` como primer prompt: depende de que Claude Code interprete el primer argumento como invocacion de slash command (verificado con `/orquestador`).
- El nuevo Claude hereda `FLEET_SOCKET`/`FLEET_SESSION` del entorno del server tmux (que `launch_fleetclaude` fija con `set-environment`), asi apunta al perfil correcto.
- `--dangerously-skip-permissions` siempre (los agentes de la flota trabajan desatendidos); riesgo asumido como en el resto del modo orquestador.
## Capability growth log
- v1.2.0 (2026-06-21) — `--socket`/`--session` ahora son opcionales: se auto-detectan del contexto tmux (`$TMUX`) via `detect_fleet_context` cuando no se pasan. Elimina el gotcha de caer a kitty cuando `$FLEET_SOCKET` viene vacia pese a estar en la flota. Los valores explicitos siguen primando.
+23 -2
View File
@@ -29,11 +29,15 @@ spawn_fleet_agent() {
--title) shift; title="${1:-claude}" ;;
-h|--help)
cat <<'USAGE'
Uso: spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [opciones]
Uso: spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [opciones]
Lanza un Claude como window nueva en la sesion tmux <session> del socket <socket>
(un perfil FleetView ya montado). Imprime el window_id creado.
--socket/--session son OPCIONALES: si no se pasan, se auto-detectan del contexto
tmux actual ($TMUX) via detect_fleet_context. Los valores explicitos tienen
prioridad. Aborta solo si tras auto-detectar siguen vacios (no hay tmux).
Opciones:
--prompt-file <f> Primer prompt del Claude = contenido del archivo (prompt
autocontenido del ejecutor). El cat lo hace el shell del
@@ -66,8 +70,25 @@ USAGE
shift
done
# Auto-detectar socket/session del contexto tmux ($TMUX) cuando no se pasan
# explicitos. Los --socket/--session explicitos SIEMPRE tienen prioridad.
# Esto evita el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
# estar dentro de una window de la flota (ver detect_fleet_context).
if [[ -z "$socket" || -z "$session" ]]; then
local _detector ctx det_socket="" det_session=""
_detector="$(dirname "${BASH_SOURCE[0]}")/detect_fleet_context.sh"
if [[ -f "$_detector" ]]; then
ctx="$(bash "$_detector" 2>/dev/null || true)"
# Parseo minimo sin depender de jq: extraer "socket":"..." / "session":"...".
det_socket="$(printf '%s' "$ctx" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')"
det_session="$(printf '%s' "$ctx" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')"
[[ -z "$socket" ]] && socket="$det_socket"
[[ -z "$session" ]] && session="$det_session"
fi
fi
[[ -z "$socket" || -z "$session" ]] && {
echo "spawn_fleet_agent: --socket y --session son obligatorios" >&2
echo "spawn_fleet_agent: no se detecto contexto tmux (\$TMUX vacia) y no se pasaron --socket/--session. Lanza desde dentro de la flota o pasa el socket/session explicito." >&2
return 2
}
[[ -z "$cwd" ]] && cwd="$PWD"
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from ab38127ac0
+18
View File
@@ -114,6 +114,24 @@ static LRESULT CALLBACK fn_subclass_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPAR
case WM_EXITSIZEMOVE:
g_in_sizemove.store(false, std::memory_order_release);
break;
case WM_SYSKEYDOWN:
// Alt+Enter would otherwise toggle a borderless-fullscreen mode
// (driven by some GPU drivers' OpenGL/Vulkan hotkey, or by
// DefWindowProc on certain window styles). We never want that:
// these are docked tool windows, not games. Consume the keystroke
// so the window stays in its normal decorated state. Every other
// Alt+key combo chains through to GLFW/DefWindowProc untouched.
if (wp == VK_RETURN) {
return 0;
}
break;
case WM_SYSCHAR:
// Swallow the system "ding" beep that the suppressed Alt+Enter
// above would otherwise trigger via the default char handler.
if (wp == VK_RETURN) {
return 0;
}
break;
case WM_LBUTTONDOWN:
// Alt + LMB anywhere on the window initiates a native modal MOVE
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
+3 -1
View File
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
@@ -57,6 +57,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
| [sql-connect](sql-connect.md) | 3 | Conexion directa y consulta a Microsoft SQL Server (Navision) via pymssql: abrir conexion (login_timeout), SELECT parametrizada con binding seguro -> {columns, rows, row_count}, y pipeline one-shot run_mssql_query (CLI JSON/CSV). Elimina el copia-pega manual de CSV de Navision. Credenciales desde pass, host = IP LAN de Windows desde WSL2 |
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
@@ -68,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` |
| [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` |
| [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
+247
View File
@@ -0,0 +1,247 @@
# 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. |
| [comfyui_interrupt_queue_py_ml](../../python/functions/ml/comfyui_interrupt_queue.md) | `interrupt_queue(server='127.0.0.1:8188') -> dict` | Corta la generación en curso (POST `/interrupt`) y lee la cola (GET `/queue`) → `{ok, interrupted, queue_running, queue_pending, error}`. Freno de mano; degrada limpio en fallo de red. Impura. |
| [comfyui_batch_generate_py_ml](../../python/functions/ml/comfyui_batch_generate.md) | `batch_generate(workflow, *, seeds=None, server='127.0.0.1:8188') -> dict` | Encola N variantes (una por seed), parcheando el campo de semilla de los nodos sampler sin mutar el original → `{ok, prompt_ids, count, error}`. Re-roll en una llamada. Compone `submit_workflow`. Impura. |
| [comfyui_queue_manage_py_ml](../../python/functions/ml/comfyui_queue_manage.md) | `queue_manage(action, *, server='127.0.0.1:8188', prompt_id=None) -> dict` | API de cola completa que complementa a `interrupt_queue`: `action='status'` (GET `/queue`), `'clear'` (vacía pendientes), `'delete'` (borra un prompt, requiere `prompt_id`), `'history'` (cuenta `/history`) → `{ok, action, queue_running, queue_pending, history_count, error}`. Degrada limpio en fallo de red. Impura. |
| [comfyui_stream_progress_py_ml](../../python/functions/ml/comfyui_stream_progress.md) | `stream_progress(prompt_id, *, server='127.0.0.1:8188', client_id=None, timeout=300) -> dict` | Progreso en vivo por WebSocket `/ws` (alternativa a `wait_result`): cuenta pasos del sampler (`steps_seen`), último nodo, y detecta el fin → `{ok, completed, steps_seen, last_node, method, error}`. Para ver progreso comparte el `client_id` con el submit. Cae a polling si falta `websocket-client`. 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_download_workflow_py_ml](../../python/functions/ml/comfyui_download_workflow.md) | `download_workflow(source, dest=None, *, server, civitai_token, hf_token, timeout) -> dict` | **Dispatcher**: descarga un workflow de CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Detecta el tipo por la URL y delega; tras bajar compone `import_workflow_json`/`import_workflow_png`. Catálogo de fuentes: `reports/0080`. 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. |
### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093)
Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la
promoción del flujo txt2img a una sola llamada. Los class_types se verificaron contra el
`/object_info` del server vivo (FaceDetailer, UltralyticsDetectorProvider, UltimateSDUpscale).
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_facedetailer_workflow_py_ml](../../python/functions/ml/comfyui_build_facedetailer_workflow.md) | `build_facedetailer_workflow(base_workflow_or_image, ckpt_name, positive, negative='', *, bbox_model='face_yolov8m.pt', denoise=0.5, ...) -> dict` | Builder **FaceDetailer** (Impact-Pack): detecta caras con `UltralyticsDetectorProvider` (YOLO bbox) y las regenera para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen en `input/` (str) o un workflow base (dict): toma la imagen del `VAEDecode` y reutiliza el `CheckpointLoaderSimple`. No usa SAM (no instalado). **Pura**. |
| [comfyui_build_hires_fix_workflow_py_ml](../../python/functions/ml/comfyui_build_hires_fix_workflow.md) | `build_hires_fix_workflow(ckpt_name, positive, negative='', *, first_pass=(768,768), upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Builder **hires fix** de 2 pasadas: genera base (KSampler) y la amplía re-difundiéndola por tiles con `UltimateSDUpscale` + Remacri (`denoise<1` = añade detalle real). Distinto de `build_upscale_workflow` (ESRGAN puro, sin re-difusión). **Pura**. |
| [comfyui_txt2img_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_txt2img_oneshot.md) | `txt2img_oneshot(prompt, *, ckpt='dreamshaper_8.safetensors', negative='', server, dest=None, wait_timeout, **gen) -> dict` | **Pipeline** texto → PNG en disco en una llamada: build_txt2img + submit + wait + fetch_output_image → `{ok, image_path, prompt_id, error}`. Promoción de la secuencia (issue 0087). Impuro. |
| [comfyui_build_grid_py_ml](../../python/functions/ml/comfyui_build_grid.md) | `build_grid(image_paths, *, cols=None, cell=512, out_path=None, labels=None) -> dict` | Monta un **grid / contact-sheet** PIL de N imágenes para comparar de un vistazo (p.ej. el output de `batch_generate` con varios seeds). Celdas que conservan aspect ratio, rejilla casi cuadrada por defecto, rótulos opcionales → `{ok, out_path, rows, cols, error}`. Post-proceso local de imagen (no toca el server). Impura (I/O disco, PIL). |
### Vídeo (txt2video) — dominio `ml` (tag `video-generation`)
ComfyUI ≥ 0.26.0 trae soporte nativo para **vídeo por difusión**. `build_video_workflow` cubre
los dos modelos que caben en 8 GB: **LTX-Video 2B v0.9.5** (`model='ltx'`, checkpoint todo-en-uno +
VAE temporal + scheduler propio — validado end-to-end en `reports/0084`, clip real de 65 frames,
pico ~7.7 GB) y **Wan2.1 T2V 1.3B** (`model='wan'`, diffusion + umt5 + vae aparte — plantilla nativa
canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
### 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`. Para mejorar la cara trasera/laterales,
genera vistas novel-view desde 1 imagen (`generate_views_from_image`, reports `0073`); para VER el GLB
resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`,
report `0079`).
| 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, ..., watertight=False) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. `watertight=True` usa `VoxelToMesh` (`algorithm='surface net'`) en vez de `VoxelToMeshBasic` → malla estanca de raíz (default conserva el comportamiento histórico). **Pura**. |
| [comfyui_generate_views_from_image_py_ml](../../python/functions/ml/comfyui_generate_views_from_image.md) | `generate_views_from_image(image_name, *, method='auto', server, azimuths=(90,180,270), elevation, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view (back/left/right) desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Honesta**: si el nodo+checkpoint no están, devuelve `ok=False` con la acción y NO encola. `validate_only=True` valida sin tocar GPU. Impura. |
| [comfyui_build_view_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_view_3d_workflow.md) | `build_view_3d_workflow(model_file, *, animation=False, width, height) -> dict` | Monta el visor 3D nativo `Load3D` (o `Load3DAdvanced` con `animation=True`) para VER un GLB/OBJ existente, orbitando con el ratón, sin ejecutar el grafo. `model_file` relativo a `input/3d/`. Cárgalo con `load_workflow_ui`. **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. |
| [comfyui_text_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_text_to_3d_oneshot.md) | `text_to_3d_oneshot(prompt, *, server, ckpt_name='v1-5-pruned-emaonly.safetensors', negative='', textured=False, variant='mini', dest=None, ...) -> dict` | **Pipeline** prompt de texto → malla 3D GLB en una llamada: txt2img (SD) + fetch + upload + build 3D (nativo o `textured=True` multi-vista PBR) + submit + wait + fetch_mesh. Promoción de la secuencia texto→imagen→3D (issue 0087). Impuro. |
| [comfyui_build_textured_3d_multiview_workflow_py_ml](../../python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md) | `build_textured_3d_multiview_workflow(image_name, *, ckpt='hunyuan3d-dit-v2-mv.safetensors', views=6, octree=384, max_faces=50000, upscale_model='4x_foolhardy_Remacri.pth') -> dict` | Builder imagen→malla 3D **con textura PBR** vía el wrapper Hunyuan3DWrapper (kijai): 4/6 vistas + delight + sample multi-vista + upscale Remacri + bake sobre UV (19 nodos). Cobertura de atlas 32.93% (report 0082). **Pura**. En 8 GB ejecutar en 2 fases (shape→`/free`→paint). |
| [comfyui_simplify_mesh_py_ml](../../python/functions/ml/comfyui_simplify_mesh.md) | `simplify_mesh(in_path, *, target_faces=80000, weld=True, out_path=None) -> dict` | **Post-proceso**: decima un GLB/OBJ/PLY denso (suelda cube-soup + quadric edge collapse de pymeshlab), conservando vertex colors o textura+UV. 964k→80k caras, 34.7→1.43 MB medido (report 0090). `weld=True` es clave: sin él la cube-soup de `VoxelToMeshBasic` no decima. Impura (trimesh+pymeshlab+scipy). |
| [comfyui_make_watertight_py_ml](../../python/functions/ml/comfyui_make_watertight.md) | `make_watertight(in_path, *, method='voxel', pitch=None, out_path=None) -> dict` | **Post-proceso**: hace estanca una malla. `method='voxel'` (voxeliza+fill+marching cubes) garantiza `is_watertight=True` a costa de más caras y de descartar la apariencia; `method='repair'` (fill_holes+fix_normals) conserva detalle pero no garantiza estanqueidad. La vía de raíz es `VoxelToMesh surface net` (report 0088). Impura. |
| [comfyui_mesh_cleanup_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_mesh_cleanup_oneshot.md) | `mesh_cleanup_oneshot(in_path, *, target_faces=80000, watertight=True, method='repair', out_path=None) -> dict` | **Pipeline** de limpieza en una llamada: `simplify_mesh` → (si `watertight`) `make_watertight`. Capitaliza el "80k caras + estanco" del report 0088. `method='voxel'` garantiza estanqueidad; `method='repair'` conserva caras. Reporta `{in_faces, simplified_faces, final_faces, is_watertight}`. 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 (ESRGAN y hires-fix con re-difusión), LoRA
stacks, inpaint, ControlNet, SDXL refiner, FaceDetailer, vídeo (LTX/Wan) y 3D texturizado
multi-vista** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`,
`build_hires_fix_workflow`, `inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`,
`build_sdxl_refiner_workflow`, `build_facedetailer_workflow`, `build_video_workflow`,
`build_textured_3d_multiview_workflow`). Lo que aún NO tiene builder propio (IPAdapter,
multi-ControlNet avanzado) se monta en la UI a mano y se captura con `export_workflow_ui`, o se
importa 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 valida con `validate_workflow` antes de encolar.
- **Los 13 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py`
+ `test_comfyui_inject_lora.py`): verifican los `class_type` esperados, que los parámetros se reflejan
en los nodos, la validez de las conexiones `[node_id, output_index]` y la pureza de `inject_lora`. Son
tests offline (no tocan GPU ni server); las funciones impuras del grupo (todo lo que habla con el server,
el navegador o Civitai/HuggingFace) no se cubren con unit tests por diseño — se validan con el server vivo.
- **Control de cola**: `interrupt_queue` corta la generación en curso + lee `/queue`; `batch_generate`
encola N variantes por seed (re-roll). No vacían la cola entera (eso es `POST /queue {"clear": true}`).
- **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 nativo 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 **textura PBR** está
`build_textured_3d_multiview_workflow`, que usa el wrapper de kijai (requiere `custom_rasterizer`
CUDA + `ComfyUI_essentials` + el upscaler Remacri) y debe ejecutarse en 2 fases en 8 GB
(shape→`/free`→paint). Detalle y cobertura medida en `reports/0082`; shape-only y comparación vs la
app local en `reports/0069-2026-06-23-comfyui-img-to-3d.md`.
- **Estanqueidad de la malla**: el default de `build_image_to_3d_workflow` (`VoxelToMeshBasic`) da
malla NO estanca; con `watertight=True` (`VoxelToMesh surface-net`) sale estanca de raíz. Si ya
tienes el GLB en disco, `mesh_cleanup_oneshot` decima + cierra en una llamada (`method='voxel'`
garantiza `is_watertight=True`; `method='repair'` conserva caras sin garantía). Ver `reports/0088`.
- 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.
+30 -16
View File
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
de fondo con los dos pasos de reconstruccion:
```
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
```
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
## Ejemplo canonico (end-to-end imagen → glb)
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
OUT = "/tmp/cats_relief.glb"
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
assert cut["status"] == "ok"
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
assert est["status"] == "ok"
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
assert res["status"] == "ok"
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
datascience). Ver gotchas en cada `.md`.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
en cada `.md`.
## Prerequisitos
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
`remove_background` cae al umbral NumPy).
+70
View File
@@ -0,0 +1,70 @@
# Capability: sql-connect
Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager).
Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. |
| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. |
| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. |
## Ejemplo canónico
One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos):
```bash
cd /home/egutierrez/fn_registry
MSSQL_PASSWORD=$(pass navision/password) \
./fn run run_mssql_query \
--host 10.0.0.5 --database navdb --user sa \
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
--param CLI-0001 \
--format csv
```
Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces):
```python
import os, sys
sys.path.insert(0, "python/functions")
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"])
try:
abiertos = mssql_query(
conn,
"SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s",
params=("CLI-0001",),
)
print(abiertos["row_count"], abiertos["columns"])
posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]")
print(posted["rows"])
finally:
conn.close()
```
## Gotchas del grupo
- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales.
- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`.
- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección).
- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga).
- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit.
- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta.
- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos.
## Fronteras
- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real.
- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook.
- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura.
## Relación con otros grupos
- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI.
- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas.
@@ -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))
@@ -5,7 +5,7 @@ lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
@@ -24,7 +24,7 @@ params:
- name: pages
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
- name: port
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
- name: timeout_s
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
fn run scrape_workana_projects it-programming es "" 1 9333 25
# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
fn run scrape_workana_projects it-programming es "" 1 9334 25
# Produccion (chromium-personal, port 9222 por defecto):
fn run scrape_workana_projects it-programming es "" 1 9222 20
# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
fn run scrape_workana_projects it-programming es "" 1 9333 25
```
```bash
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
--category it-programming --language es --port 9222
--category it-programming --language es --port 9334
```
```python
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
## Gotchas
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
perfil headless dedicado del scraping, que levanta/cierra el wrapper
`monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
sirve para smoke interactivo. Sin Chrome escuchando devuelve
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
@@ -198,7 +198,7 @@ def scrape_workana_projects(
language: str = "es",
extra_query: str = "",
pages: int = 1,
port: int = 9222,
port: int = 9334,
timeout_s: float = 20.0,
) -> dict:
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
@@ -217,9 +217,12 @@ def scrape_workana_projects(
filtrar por palabra clave (ej. "python", "scraping").
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
adicional se navega con &page=N.
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
9222 por defecto: ese es el chromium-personal del usuario y el scraping
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
recon) tambien sirve 9333 (el del browser_mcp).
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
el polling de aparicion de cards. Default 20.0.
@@ -293,7 +296,7 @@ if __name__ == "__main__":
parser.add_argument("--language", default="es")
parser.add_argument("--extra-query", default="")
parser.add_argument("--pages", type=int, default=1)
parser.add_argument("--port", type=int, default=9222)
parser.add_argument("--port", type=int, default=9334)
parser.add_argument("--timeout-s", type=float, default=20.0)
args = parser.parse_args()
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
kind: function
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
uses_functions: []
uses_types: []
@@ -25,7 +25,9 @@ params:
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
- name: max_dim
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
- name: mask
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
tested: false
tests: []
test_file_path: ""
@@ -81,3 +83,14 @@ suavizar el relieve.
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
`estimate_image_depth`.
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
## Capability growth log
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
out_glb_path: str,
z_scale: float = 0.35,
max_dim: int = 220,
mask: "np.ndarray | None" = None,
) -> dict:
"""
Construye una malla de relieve texturizada y la exporta como .glb.
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
Default 220 (~48k vértices, ~96k caras).
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
H, W = depth.shape
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
fg = None
if mask is not None:
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
fg = np.asarray(mask_img) >= 128
depth = np.where(fg, depth, 0.0).astype(np.float32)
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
aspect = W / float(H)
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
]
)
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
if fg is not None:
keep = fg.ravel()[faces].all(axis=1)
faces = faces[keep]
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
@@ -0,0 +1,89 @@
---
name: remove_background
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_path
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
- name: engine
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/remove_background.py"
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
---
## Ejemplo
```python
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
assert res["status"] == "ok"
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
print(res["height"], res["width"]) # p.ej. 1024 768
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
assert 0.0 < res["fg_fraction"] < 1.0
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
```
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
```bash
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
```
## Cuando usarla
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
catalogos, datasets, miniaturas).
## Gotchas
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
interprete muere.
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
status error (motor no instalado, fallo, o mascara degenerada).
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
esa eleccion e ignora el alfa existente.
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
`from remove_background import remove_background`), NO `from datascience import ...`. El
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
funcion que romperian el import del paquete en el venv de vision.
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
`{status:'error', error:str}`.
@@ -0,0 +1,213 @@
"""
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
neutro.
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
# Fondo gris neutro sobre el que se compone el objeto recortado.
NEUTRAL_BG = (127, 127, 127)
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
_ALPHA_THRESH = 128
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
_REMBG_SESSION = None
def _existing_alpha_mask(image):
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
if alpha.min() < _ALPHA_THRESH:
return alpha
return None
def _composite_over_neutral(image_rgb, mask):
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
bg = np.empty_like(rgb)
bg[:] = NEUTRAL_BG
out = rgb * alpha + bg * (1.0 - alpha)
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
def _remove_with_rembg(image):
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
global _REMBG_SESSION
from rembg import new_session, remove
if _REMBG_SESSION is None:
_REMBG_SESSION = new_session("u2net")
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
return mask, "rembg:u2net"
def _remove_with_grabcut(image):
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
import cv2
rgb = np.asarray(image.convert("RGB"))
h, w = rgb.shape[:2]
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
gc_mask = np.zeros((h, w), np.uint8)
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
return fg, "opencv:grabcut"
def _remove_with_threshold(image):
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
h, w = rgb.shape[:2]
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
bg_color = border.mean(axis=0)
dist = np.linalg.norm(rgb - bg_color, axis=2)
thresh = max(30.0, float(dist.mean()))
fg = (dist > thresh).astype(np.uint8) * 255
return fg, "threshold:border"
def remove_background(image_path: str, engine: str = "auto") -> dict:
"""
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
Parámetros:
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
o la máscara resulta degenerada, se devuelve status error.
Devuelve (dict, nunca lanza):
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
redondeada a 4 decimales)}.
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
no disponible/fallido, o ningún motor produjo una máscara válida).
"""
try:
image = Image.open(image_path)
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
if engine == "auto":
existing = _existing_alpha_mask(image)
if existing is not None:
composed = _composite_over_neutral(image, existing)
frac = float((existing >= 128).mean())
h, w = existing.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": existing,
"engine": "passthrough:alpha",
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
# Construir la lista de motores a probar según el engine pedido.
if engine == "auto":
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
elif engine == "rembg":
attempts = [_remove_with_rembg]
elif engine == "grabcut":
attempts = [_remove_with_grabcut]
elif engine == "threshold":
attempts = [_remove_with_threshold]
else:
attempts = []
if not attempts:
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
last_exc = None
for attempt in attempts:
try:
mask, used = attempt(image)
except Exception as e: # noqa: BLE001
last_exc = e
continue
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
frac = float((mask >= 128).mean())
if frac < 0.01 or frac > 0.995:
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
continue
composed = _composite_over_neutral(image, mask)
h, w = mask.shape[:2]
return {
"status": "ok",
"image": composed,
"mask": mask,
"engine": used,
"height": int(h),
"width": int(w),
"fg_fraction": round(frac, 4),
}
return {
"status": "error",
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
import json
import os
import sys
if len(sys.argv) < 2:
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
sys.exit(1)
path = sys.argv[1]
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
res = remove_background(path, engine=eng)
if res["status"] == "ok":
summary = {
"status": "ok",
"engine": res["engine"],
"height": res["height"],
"width": res["width"],
"fg_fraction": res["fg_fraction"],
}
if out_dir:
os.makedirs(out_dir, exist_ok=True)
rgb_path = os.path.join(out_dir, "rgb.png")
mask_path = os.path.join(out_dir, "mask.png")
res["image"].save(rgb_path)
Image.fromarray(res["mask"]).save(mask_path)
summary["rgb_path"] = rgb_path
summary["mask_path"] = mask_path
print(json.dumps(summary))
else:
print(json.dumps(res))
sys.exit(1)
+81
View File
@@ -0,0 +1,81 @@
---
name: mssql_connect
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_connect(host: str, database: str, user: str, password: str, port: int = 1433, login_timeout: int = 15, query_timeout: int = 30) -> pymssql.Connection"
description: "Abre una conexion pymssql a un Microsoft SQL Server (donde corre Navision). Las credenciales llegan siempre por argumento (el caller las saca de pass/env), nunca hardcodeadas. login_timeout acota la fase de conexion/login para evitar cuelgues con un host inalcanzable. Devuelve el objeto conexion pymssql para iterar queries despues."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pymssql]
params:
- name: host
desc: "Host o IP del servidor SQL Server. Desde WSL2 debe ser la IP LAN de Windows (ej. 10.0.0.5), no localhost."
- name: database
desc: "Nombre de la base de datos a la que conectar (ej. navdb)."
- name: user
desc: "Usuario de login de SQL Server (ej. sa)."
- name: password
desc: "Contrasena del usuario de login. Se pasa desde pass/env, nunca como literal."
- name: port
desc: "Puerto TCP del SQL Server. Por defecto 1433. La funcion lo convierte a string porque pymssql lo exige asi."
- name: login_timeout
desc: "Segundos permitidos para la fase de conexion/login antes de fallar. Por defecto 15. Evita que un host inalcanzable cuelgue indefinidamente."
- name: query_timeout
desc: "Segundos permitidos para cada query ejecutada sobre la conexion devuelta antes de hacer timeout. Por defecto 30."
output: "Un objeto pymssql.Connection abierto. El caller es responsable de cerrarlo con .close() al terminar."
tested: true
tests: ["test_golden_connect_passes_string_port_and_kwargs", "test_error_path_wraps_failure_with_host"]
test_file_path: "python/functions/infra/mssql_connect_test.py"
file_path: "python/functions/infra/mssql_connect.py"
---
## Ejemplo
```python
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.mssql_connect import mssql_connect
# La IP debe ser la IP LAN del servidor Windows: desde WSL2 "localhost" NO
# llega al host Windows. La contrasena llega del entorno, nunca literal.
conn = mssql_connect(
host="10.0.0.5",
database="navdb",
user="sa",
password=os.environ["MSSQL_PASSWORD"],
port=1433,
login_timeout=15,
)
try:
with conn.cursor() as cur:
cur.execute("SELECT TOP 1 name FROM sys.databases")
print(cur.fetchone())
finally:
conn.close()
```
## Cuando usarla
Usala cuando necesites abrir una conexion a un Microsoft SQL Server (donde
corre Navision) antes de iterar queries con `mssql_query`. Es el primer paso
de cualquier pipeline que lea datos de Navision: abre la conexion una vez,
reutilizala para varias queries, y cierrala al final. Triggers: "conecta a
Navision", "lee de SQL Server", "abre conexion mssql".
## Gotchas
- WSL2 -> Windows: usa la IP LAN del servidor Windows, NUNCA `localhost`. Desde dentro de WSL2 `localhost` no alcanza el host Windows (el reenvio de localhost solo funciona Windows -> WSL, no al reves).
- pymssql necesita el puerto como string. La funcion ya convierte `port` a `str(port)` internamente, asi que tu pasas un int normal.
- `login_timeout` esta acotado (15s por defecto) precisamente para que un host inalcanzable o mal configurado falle con un RuntimeError claro en vez de colgarse indefinidamente. Ajustalo si la red es lenta, pero no lo dejes sin limite.
- Credenciales NUNCA hardcodeadas: `user`/`password` llegan por argumento desde `pass`/env. No las escribas literales en el codigo del caller.
- Cierra la conexion con `.close()` al terminar (idealmente en un `finally`). La funcion devuelve un handle abierto y no gestiona su ciclo de vida.
- Requiere `pymssql` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
+65
View File
@@ -0,0 +1,65 @@
"""Open a connection to a Microsoft SQL Server (Navision) via pymssql."""
from __future__ import annotations
def mssql_connect(host: str, database: str, user: str, password: str,
port: int = 1433, login_timeout: int = 15,
query_timeout: int = 30):
"""Open a connection to a Microsoft SQL Server instance (e.g. Navision).
Uses the pymssql driver. Credentials are always supplied by the caller
(typically read from `pass`/env) and never hardcoded. The connection is
impure I/O: it touches the network and the database server.
pymssql expects the TCP port as a string, so `port` is converted before
being passed through. `login_timeout` bounds the connect/login phase, which
is what keeps an invalid host from hanging indefinitely; `query_timeout`
bounds individual queries run on the resulting connection.
Args:
host: SQL Server host or IP. From WSL2 this must be the Windows LAN IP
(e.g. "10.0.0.5"), not "localhost" — localhost does not reach the
Windows host from inside WSL2.
database: Name of the database to connect to (e.g. "navdb").
user: SQL Server login user (e.g. "sa").
password: Password for the login user. Pass it from `pass`/env, never
as a string literal.
port: TCP port of the SQL Server instance. Defaults to 1433. Converted
to a string internally because pymssql requires a string port.
login_timeout: Seconds allowed for the connect/login phase before it
fails. Defaults to 15. Keeps an unreachable host from hanging.
query_timeout: Seconds allowed for each query executed on the returned
connection before it times out. Defaults to 30.
Returns:
An open pymssql.Connection. The caller is responsible for closing it
with `.close()` when done.
Raises:
RuntimeError: If pymssql is not installed, or if the connection/login
fails. The message includes host:port and database for context and
the original exception is chained for debugging.
"""
# Lazy import so the module loads even without pymssql installed.
try:
import pymssql
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"pymssql is required for mssql_connect; install pymssql"
) from exc
try:
return pymssql.connect(
server=host,
user=user,
password=password,
database=database,
port=str(port),
login_timeout=login_timeout,
timeout=query_timeout,
)
except Exception as exc:
raise RuntimeError(
f"mssql_connect failed connecting to {host}:{port}/{database}: {exc}"
) from exc
@@ -0,0 +1,59 @@
"""Tests for mssql_connect (mock-based, no real SQL Server)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from mssql_connect import mssql_connect
def test_golden_connect_passes_string_port_and_kwargs(monkeypatch):
"""Golden path: returns the driver connection and forwards the right kwargs.
The TCP port must reach pymssql as a STRING, and login_timeout must default
to 15 when not supplied.
"""
captured: dict = {}
sentinel = object()
def fake_connect(**kwargs):
captured.update(kwargs)
return sentinel
monkeypatch.setattr("pymssql.connect", fake_connect)
result = mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
assert result is sentinel
assert captured["server"] == "10.0.0.5"
assert captured["database"] == "navdb"
assert captured["user"] == "sa"
assert captured["password"] == "pw"
assert captured["port"] == "1433"
assert isinstance(captured["port"], str)
assert captured["login_timeout"] == 15
assert captured["timeout"] == 30
def test_error_path_wraps_failure_with_host(monkeypatch):
"""Error path: a driver failure becomes a clear RuntimeError, not a hang.
The wrapped message must include the host and the phrase 'failed connecting'
so callers can diagnose connectivity problems.
"""
def fake_connect(**kwargs):
raise Exception("login timeout")
monkeypatch.setattr("pymssql.connect", fake_connect)
with pytest.raises(RuntimeError) as excinfo:
mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
message = str(excinfo.value)
assert "10.0.0.5" in message
assert "failed connecting" in message
+78
View File
@@ -0,0 +1,78 @@
---
name: mssql_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict"
description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"]
test_file_path: "python/functions/infra/mssql_query_test.py"
params:
- name: conn
desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()."
- name: sql
desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular."
- name: params
desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)."
- name: max_rows
desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)."
output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))."
file_path: "python/functions/infra/mssql_query.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect(
host="10.0.0.5", database="navdb", user="readonly", password="<desde pass>"
)
try:
res = mssql_query(
conn,
"SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
("CLI-0001",),
)
print(res["columns"]) # ['No_', 'Amount']
print(res["row_count"]) # numero de filas devueltas
for fila in res["rows"]:
print(fila["No_"], fila["Amount"])
finally:
conn.close()
```
## Cuando usarla
Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar
consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada
una. Pasa los valores variables como `params` para que el driver los vincule de
forma segura (sin inyeccion) en lugar de construir el SQL con f-strings.
## Gotchas
- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado),
NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla.
- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%`
dentro del SQL: interpolar abre la puerta a inyeccion SQL.
- No hace commit: es read-only, pensada para SELECT.
- No cierra la conexion — la gestiona el caller (abrir una vez, consultar
muchas, cerrar al final).
- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`.
- Si la sentencia no produce result set (`cursor.description is None`),
`columns` y `rows` vuelven como listas vacias en lugar de fallar.
- El mensaje de error es generico a proposito: no incluye el SQL ni los params
para no filtrar datos sensibles.
+77
View File
@@ -0,0 +1,77 @@
"""Run a parameterized SELECT over an open pymssql (SQL Server / Navision) connection."""
from __future__ import annotations
def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict:
"""Execute a SELECT on an already-open connection and map rows to dicts.
The connection is supplied by the caller (typically from `mssql_connect`),
so a single connection can be opened once and reused for many queries. This
function never opens or closes the connection it only borrows it. It is
impure I/O: it touches the database over an existing connection.
Parameter binding is delegated to the driver: `params` is passed straight to
`cursor.execute(sql, params)`. NEVER interpolate values into `sql` with
f-strings or `%` formatting that opens the door to SQL injection. Use the
pymssql placeholders `%s` (positional) or `%(name)s` (named) in `sql` and
let the driver bind safely. When `params is None`, the SQL is executed with
no bound parameters.
The query runs read-only: no commit is issued. The cursor opened here is
always closed before returning (try/finally), even on error.
Args:
conn: An open connection object (e.g. the one returned by
`mssql_connect`). Used by duck typing via `conn.cursor()`, so the
concrete driver does not matter and the function stays testable.
sql: The SELECT statement, using pymssql placeholders `%s` (positional)
or `%(name)s` (named) for any bound values.
params: A tuple/list for positional placeholders, a dict for named
placeholders, or None for a query with no parameters. Passed to
`cursor.execute(sql, params)` for safe driver-side binding.
max_rows: If a positive int, only the first `max_rows` rows are fetched
(via `cursor.fetchmany(max_rows)`). If None, all rows are fetched
(via `cursor.fetchall()`).
Returns:
A dict with three keys:
- "columns": list of column names in result order (empty list if the
statement produced no result set, i.e. `cursor.description is None`).
- "rows": list of dicts, one per row, mapping each column name to its
value. Empty list when the query returned no rows.
- "row_count": int, equal to `len(rows)`.
Raises:
RuntimeError: If executing or fetching the query fails. The message is
deliberately generic (it does not include the SQL or the params,
which may carry sensitive data) and the original exception is
chained for debugging.
"""
cur = conn.cursor()
try:
try:
if params is None:
cur.execute(sql)
else:
cur.execute(sql, params)
description = cur.description
if description is None:
columns: list = []
raw_rows: list = []
else:
columns = [d[0] for d in description]
if max_rows is not None and max_rows > 0:
raw_rows = cur.fetchmany(max_rows)
else:
raw_rows = cur.fetchall()
except Exception as exc:
raise RuntimeError(
f"mssql_query failed executing query: {exc}"
) from exc
finally:
cur.close()
rows = [dict(zip(columns, row)) for row in raw_rows]
return {"columns": columns, "rows": rows, "row_count": len(rows)}
+133
View File
@@ -0,0 +1,133 @@
"""Tests para mssql_query usando un doble de prueba (sin servidor real)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.mssql_query import mssql_query
def _desc(*names):
"""Construye una description estilo DB-API: una tupla 7-elem por columna."""
return [(name, None, None, None, None, None, None) for name in names]
class FakeCursor:
"""Doble de prueba de un cursor DB-API (pymssql-like)."""
def __init__(self, description=None, rows=None):
self.description = description
self._rows = list(rows or [])
self.executed = None # (sql, params) de la ultima execute
self.fetchmany_calls = [] # tamaños pedidos a fetchmany
self.closed = False
def execute(self, sql, params=None):
self.executed = (sql, params)
def fetchall(self):
return list(self._rows)
def fetchmany(self, size):
self.fetchmany_calls.append(size)
return list(self._rows[:size])
def close(self):
self.closed = True
class FakeConn:
"""Doble de prueba de una conexion: devuelve un FakeCursor fijo."""
def __init__(self, cursor):
self._cursor = cursor
def cursor(self):
return self._cursor
def test_golden_maps_rows_to_dicts():
cur = FakeCursor(
description=_desc("No_", "Amount"),
rows=[("CLI-1", 100), ("CLI-2", 200)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera")
assert result == {
"columns": ["No_", "Amount"],
"rows": [
{"No_": "CLI-1", "Amount": 100},
{"No_": "CLI-2", "Amount": 200},
],
"row_count": 2,
}
assert cur.closed is True
def test_binding_passes_params_to_driver():
cur = FakeCursor(description=_desc("No_"), rows=[("CLI-0001",)])
conn = FakeConn(cur)
sql = "SELECT No_ FROM Cartera WHERE [Customer No_] = %s"
mssql_query(conn, sql, params=("CLI-0001",))
# El SQL y los params llegan al driver tal cual: binding, no interpolacion.
assert cur.executed == (sql, ("CLI-0001",))
def test_zero_rows_no_error():
cur = FakeCursor(description=_desc("No_", "Amount"), rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera WHERE 1 = 0")
assert result["rows"] == []
assert result["row_count"] == 0
assert result["columns"] == ["No_", "Amount"]
def test_max_rows_uses_fetchmany():
cur = FakeCursor(
description=_desc("No_"),
rows=[("CLI-1",), ("CLI-2",), ("CLI-3",)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_ FROM Cartera", max_rows=1)
assert cur.fetchmany_calls == [1]
assert result["row_count"] == 1
assert result["rows"] == [{"No_": "CLI-1"}]
def test_description_none_empty_columns():
cur = FakeCursor(description=None, rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SET NOCOUNT ON")
assert result["columns"] == []
assert result["rows"] == []
assert result["row_count"] == 0
def test_execution_error_raises_runtimeerror():
class BoomCursor(FakeCursor):
def execute(self, sql, params=None):
raise ValueError("boom")
cur = BoomCursor()
conn = FakeConn(cur)
with pytest.raises(RuntimeError, match="mssql_query failed executing query"):
mssql_query(conn, "SELECT 1")
# El cursor se cierra incluso en error (try/finally).
assert cur.closed is True
@@ -0,0 +1,75 @@
---
name: comfyui_batch_generate
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_batch_generate(workflow: dict, *, seeds: list | None = None, server: str = \"127.0.0.1:8188\") -> dict"
description: "Encola N variantes de un workflow ComfyUI, una por seed de la lista, parcheando el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced/SamplerCustom.noise_seed) sin mutar el original (deepcopy), y recoge cada prompt_id. Compone comfyui_submit_workflow. Util para barridos de re-roll: misma escena, varias semillas, una sola llamada. Devuelve {ok, prompt_ids, count, error}. Impura: HTTP POST por variante, solo stdlib."
tags: [comfyui, ml, batch, seeds, queue, http]
uses_functions: ["comfyui_submit_workflow_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: workflow
desc: "dict en API format (resultado de un builder). No se muta: cada variante es una copia profunda con la semilla parcheada."
- name: seeds
desc: "Lista de semillas (int); cada una produce una variante encolada. None o vacia encola el workflow tal cual una sola vez. keyword-only."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188'). keyword-only."
output: "dict con ok (bool, True si TODAS las variantes se encolaron), prompt_ids (list[str] en orden de seeds, para comfyui_wait_result), count (int, variantes encoladas con exito), error (str, primer error; vacio si OK). Si una variante falla, detiene el barrido y devuelve los prompt_ids ya encolados."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_batch_generate.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_batch_generate import comfyui_batch_generate
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",
)
res = comfyui_batch_generate(wf, seeds=[1, 2, 3])
# {'ok': True, 'prompt_ids': ['<id1>', '<id2>', '<id3>'], 'count': 3, 'error': ''}
for pid in res["prompt_ids"]:
pass # comfyui_wait_result(pid) para recoger cada resultado
```
O lanzable directo (build txt2img + encolar 2 seeds) con: `./fn run comfyui_batch_generate`.
## Cuando usarla
Para generar varias variantes de la misma escena cambiando solo la semilla
(re-roll de calidad) en una sola llamada, en vez de editar el seed y reenviar a
mano N veces. Aplica a cualquier workflow con nodo sampler: txt2img, img2img,
video (parchea `noise_seed` del SamplerCustom de LTX), etc. Tras encolar, sigue
cada `prompt_id` con `comfyui_wait_result`.
## Gotchas
- Parchea TODO input llamado `seed` o `noise_seed` en cualquier nodo. Si un
workflow tiene varios samplers, todos reciben la misma semilla de la variante
(normalmente lo deseado). Si necesitas semillas independientes por sampler,
parchea a mano.
- Encolar tiene efecto secundario: arranca trabajo de GPU. N seeds = N prompts en
cola = N corridas de GPU en serie. En 8GB, no encoles 20 videos a la vez sin
vigilar VRAM/tiempo.
- `seeds=None` encola el workflow tal cual UNA vez (sin tocar la semilla): util
como "submit con la firma de batch".
- Fail-fast: si una variante es rechazada (HTTP 400), detiene el barrido,
devuelve `ok=False` + `error` y los `prompt_ids` ya encolados (no hace rollback
de los anteriores — ya estan en la cola del servidor).
- Si necesitas cortar un barrido a medias, usa `comfyui_interrupt_queue` (corta el
que se ejecuta) o `POST /queue {"clear": true}` para vaciar los pendientes.
@@ -0,0 +1,91 @@
"""Encola N variantes de un workflow ComfyUI, una por seed, y recoge los prompt_ids.
Funcion impura: hace red (POST /prompt por variante, via comfyui_submit_workflow).
Compone comfyui_submit_workflow.
Para cada seed de la lista, copia el workflow (deepcopy, no muta el original),
parchea el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced.
noise_seed, SamplerCustom.noise_seed en general cualquier input "seed"/"noise_seed")
y lo encola. Util para barridos de re-roll: misma escena, varias semillas, una sola
llamada. Devuelve los prompt_ids en el mismo orden que la lista de seeds; cada uno
se sigue con comfyui_wait_result.
"""
import copy
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_submit_workflow import comfyui_submit_workflow # noqa: E402
# Campos de semilla conocidos en los nodos sampler de ComfyUI.
_SEED_KEYS = ("seed", "noise_seed")
def _patch_seed(workflow: dict, seed: int) -> dict:
"""Copia el workflow y fija `seed` en todos los inputs de semilla (no muta el original)."""
wf = copy.deepcopy(workflow)
for node in wf.values():
inputs = node.get("inputs")
if not isinstance(inputs, dict):
continue
for key in _SEED_KEYS:
if key in inputs:
inputs[key] = seed
return wf
def comfyui_batch_generate(
workflow: dict,
*,
seeds: list | None = None,
server: str = "127.0.0.1:8188",
) -> dict:
"""Encola una variante del workflow por cada seed y devuelve los prompt_ids.
Args:
workflow: dict en API format (resultado de un builder). No se muta: cada
variante es una copia profunda con la semilla parcheada.
seeds: lista de semillas (int). Cada una produce una variante encolada. Si
es None o vacia, se encola el workflow tal cual una sola vez (sin
parchear semilla). keyword-only.
server: host:port del servidor ComfyUI sin esquema. keyword-only.
Returns:
dict con:
- ok (bool): True si TODAS las variantes se encolaron sin error.
- prompt_ids (list[str]): prompt_id de cada variante encolada, en orden.
- count (int): numero de variantes encoladas con exito.
- error (str): primer error encontrado; cadena vacia si todo OK. Si una
variante falla, se detiene el barrido y se devuelven los prompt_ids ya
encolados.
"""
out = {"ok": False, "prompt_ids": [], "count": 0, "error": ""}
variants = [(s, _patch_seed(workflow, s)) for s in seeds] if seeds else [(None, workflow)]
for seed, wf in variants:
try:
resp = comfyui_submit_workflow(wf, server=server)
except RuntimeError as exc:
label = "tal cual" if seed is None else f"seed={seed}"
out["error"] = f"variante {label} fallo al encolar: {exc}"
return out
out["prompt_ids"].append(resp["prompt_id"])
out["count"] = len(out["prompt_ids"])
out["ok"] = True
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",
)
res = comfyui_batch_generate(wf, seeds=[1, 2])
print(f"ok={res['ok']} count={res['count']} ids={res['prompt_ids']} error={res['error']!r}")
@@ -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: true
tests: ["usa ControlNetLoader+ControlNetApply", "control_image, modelo cn y strength reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_controlnet_workflow.py"
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,115 @@
---
name: comfyui_build_facedetailer_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_facedetailer_workflow(base_workflow_or_image, ckpt_name: str, positive: str, negative: str = \"\", *, bbox_model: str = \"face_yolov8m.pt\", denoise: float = 0.5, steps: int = 20, cfg: float = 8.0, seed: int = 0, guide_size: float = 512.0, bbox_threshold: float = 0.5, feather: int = 5, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"facedetail\") -> dict"
description: "Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format: detecta caras con UltralyticsDetectorProvider (YOLO bbox) y las regenera con un sampler de difusion para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen ya en input/ (modo str) o un workflow base como dict (modo workflow, p.ej. el de comfyui_build_txt2img_workflow): en este caso toma la imagen del VAEDecode y reutiliza el CheckpointLoaderSimple. Class_types reales verificados en /object_info. Pura, sin red ni I/O."
tags: [comfyui, ml, facedetailer, impact-pack, portrait, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: base_workflow_or_image
desc: "Nombre (str) de una imagen ya en el input/ del servidor, o un workflow base (dict en API format). Con str monta LoadImage + CheckpointLoaderSimple nuevos; con dict toma la imagen del primer VAEDecode y reutiliza su CheckpointLoaderSimple."
- name: ckpt_name
desc: "Checkpoint para el sampler del detailer (y para el loader nuevo en modo imagen). Debe existir en el servidor (CheckpointLoaderSimple)."
- name: positive
desc: "Prompt positivo para regenerar las caras (ej. 'detailed face, sharp eyes, skin texture'). Se codifica con el CLIP del checkpoint."
- name: negative
desc: "Prompt negativo. Por defecto ''."
- name: bbox_model
desc: "Modelo de deteccion Ultralytics. Acepta nombre corto ('face_yolov8m.pt') o prefijado ('bbox/face_yolov8m.pt'); si no trae prefijo se asume 'bbox/'. keyword-only."
- name: denoise
desc: "Fuerza de re-difusion de cada cara (0.5 por defecto; mas alto = mas cambio, mas riesgo de perder identidad). keyword-only."
- name: steps
desc: "Pasos de sampling del detailer. keyword-only."
- name: cfg
desc: "Classifier-free guidance del detailer. keyword-only."
- name: seed
desc: "Semilla del sampler del detailer. keyword-only."
- name: guide_size
desc: "Tamano (px) al que se reescala cada cara recortada antes de re-difundirla (FaceDetailer.guide_size). keyword-only."
- name: bbox_threshold
desc: "Umbral de confianza del detector de caras (0..1). Mas alto = menos falsos positivos, riesgo de no detectar caras pequenas. keyword-only."
- name: feather
desc: "Pixeles de difuminado del borde de la mascara al recomponer la cara sobre la imagen. keyword-only."
- name: sampler_name
desc: "Sampler del detailer (ej. 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del detailer (ej. 'normal'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. En modo dict contiene los nodos del workflow base mas los del detailer (node_ids prefijados 'fd_' para no colisionar); el SaveImage 'fd_save' produce la imagen con las caras regeneradas."
tested: true
tests: ["modo imagen monta UltralyticsDetectorProvider + FaceDetailer + SaveImage", "modo workflow reutiliza VAEDecode y CheckpointLoaderSimple del base y conserva sus nodos", "normaliza bbox_model corto a prefijo bbox/", "dict sin VAEDecode lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_facedetailer_workflow.py"
file_path: "python/functions/ml/comfyui_build_facedetailer_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_facedetailer_workflow import comfyui_build_facedetailer_workflow
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
# Modo workflow: genera un retrato y le aplica FaceDetailer en el mismo grafo.
base = comfyui_build_txt2img_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="portrait of a woman, soft light",
width=512, height=768, seed=7,
)
wf = comfyui_build_facedetailer_workflow(
base,
ckpt_name="dreamshaper_8.safetensors",
positive="detailed face, sharp eyes, skin texture",
negative="blurry, deformed",
denoise=0.45,
)
# wf["fd_det"]["class_type"] == "UltralyticsDetectorProvider"
# wf["fd_det"]["inputs"]["model_name"] == "bbox/face_yolov8m.pt"
# wf["fd_face"]["class_type"] == "FaceDetailer"
# wf["fd_face"]["inputs"]["image"] == ["8", 0] # VAEDecode del base
# wf["4"]["class_type"] == "CheckpointLoaderSimple" # nodos del base conservados
```
O lanzable directo con: `./fn run comfyui_build_facedetailer_workflow` (imprime el JSON del workflow de ejemplo en modo imagen).
## Cuando usarla
Cuando una imagen generada tiene caras mediocres (ojos borrosos, piel plana,
rasgos deformados) y quieres regenerarlas con detalle sin rehacer toda la imagen.
Es el ADetailer/FaceDetailer "pro" del flujo de retratos. Encadénala tras
`comfyui_build_txt2img_workflow` (pásale el dict) para detail en una sola cola, o
pásale el nombre de una imagen ya en `input/` para mejorar una imagen existente.
Después: `comfyui_submit_workflow``comfyui_wait_result``comfyui_fetch_output_image`.
## Gotchas
- Es API format (nodos numerados / con prefijo `fd_`), NO el formato de la UI.
- Requiere **ComfyUI-Impact-Pack** instalado (provee `FaceDetailer` y
`UltralyticsDetectorProvider`). Si el server responde HTTP 400 "node type not
found: FaceDetailer", el custom node no está cargado: revísalo en el Manager.
- El modelo de detección debe estar en `models/ultralytics/bbox/` (aquí
`face_yolov8m.pt`). El nodo lo referencia con prefijo de subcarpeta
(`bbox/face_yolov8m.pt`); la función normaliza el nombre corto automáticamente.
- **No usa SAM** (segment-anything): `sam_model_opt` es opcional y aquí no hay
modelo SAM instalado (`SAMLoader` reporta lista vacía). FaceDetailer funciona
solo con el detector de bounding box, que basta para caras. Si instalas un SAM
y quieres máscaras más finas, habría que añadir el `SAMLoader` aparte.
- En **modo workflow** (dict) se reutiliza el primer `CheckpointLoaderSimple` y el
primer `VAEDecode` del base. Si el base usa otro loader (p.ej. un flujo SDXL con
loaders distintos), se monta un `CheckpointLoaderSimple` propio con `ckpt_name`
asegúrate de que el checkpoint case con el espacio latente del base.
- El SaveImage del workflow base (si lo tenía) se conserva: el grafo produce tanto
la imagen base como la "detailed" (`fd_save`). Si solo quieres la final, ignora
la otra salida.
- `denoise` alto (>0.6) puede cambiar la identidad de la cara; 0.40.5 conserva
rasgos y añade detalle.
@@ -0,0 +1,230 @@
"""Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format.
FaceDetailer es el nodo estrella de ComfyUI-Impact-Pack para el "pain #1" de los
retratos: detecta las caras de una imagen (con un detector YOLO via
UltralyticsDetectorProvider) y regenera cada una por separado con un sampler de
difusion, recuperando detalle (ojos, piel, dientes) que el primer render pierde.
Esta funcion monta el sub-grafo del detailer y lo conecta a una fuente de imagen,
que puede ser:
- una imagen ya subida al `input/` del servidor (pasa su nombre como str), o
- un workflow base ya construido (pasa el dict, p.ej. el de
`comfyui_build_txt2img_workflow`): el detailer toma la imagen del `VAEDecode`
del workflow y reutiliza su `CheckpointLoaderSimple` (model/clip/vae).
Cadena del sub-grafo (sobre los class_types REALES de Impact-Pack, verificados en
`/object_info`):
UltralyticsDetectorProvider -> BBOX_DETECTOR
CheckpointLoaderSimple (nuevo o reutilizado) -> MODEL, CLIP, VAE
CLIPTextEncode (positive, negative) -> CONDITIONING
FaceDetailer(image, model, clip, vae, positive, negative, bbox_detector, ...) -> IMAGE
SaveImage
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
from __future__ import annotations
def _normalize_bbox_model(name: str) -> str:
"""Normaliza el nombre del modelo de deteccion al formato del nodo.
UltralyticsDetectorProvider expone los modelos con prefijo de subcarpeta
(`bbox/face_yolov8m.pt`, `segm/person_yolov8m-seg.pt`). Acepta tanto el
nombre corto (`face_yolov8m.pt`) como el ya prefijado; si no trae prefijo
`bbox/` ni `segm/`, asume `bbox/` (caras/manos son detectores de bounding box).
"""
if name.startswith(("bbox/", "segm/")):
return name
return f"bbox/{name}"
def _find_first(workflow: dict, class_type: str) -> str | None:
"""Devuelve el node_id del primer nodo con ese class_type, o None."""
for node_id, node in workflow.items():
if isinstance(node, dict) and node.get("class_type") == class_type:
return node_id
return None
def comfyui_build_facedetailer_workflow(
base_workflow_or_image,
ckpt_name: str,
positive: str,
negative: str = "",
*,
bbox_model: str = "face_yolov8m.pt",
denoise: float = 0.5,
steps: int = 20,
cfg: float = 8.0,
seed: int = 0,
guide_size: float = 512.0,
bbox_threshold: float = 0.5,
feather: int = 5,
sampler_name: str = "euler",
scheduler: str = "normal",
filename_prefix: str = "facedetail",
) -> dict:
"""Construye un workflow ComfyUI que aplica FaceDetailer a una imagen.
Args:
base_workflow_or_image: o bien el nombre (str) de una imagen ya presente
en el `input/` del servidor, o bien un workflow base (dict en API
format, p.ej. el de `comfyui_build_txt2img_workflow`). Con str se monta
un `LoadImage` y un `CheckpointLoaderSimple` nuevos; con dict se toma la
imagen del primer `VAEDecode` y se reutiliza su `CheckpointLoaderSimple`.
ckpt_name: checkpoint para el sampler del detailer (y para el loader nuevo
en el modo imagen). Debe existir en el servidor (CheckpointLoaderSimple).
positive: prompt positivo para regenerar las caras (p.ej. "detailed face,
sharp eyes, skin texture"). Se codifica con el CLIP del checkpoint.
negative: prompt negativo. Por defecto "".
bbox_model: modelo de deteccion de Ultralytics. Acepta nombre corto
("face_yolov8m.pt") o prefijado ("bbox/face_yolov8m.pt"). keyword-only.
denoise: fuerza de re-difusion de cada cara (0.5 por defecto; mas alto =
mas cambio, mas riesgo de perder identidad). keyword-only.
steps: pasos de sampling del detailer. keyword-only.
cfg: classifier-free guidance del detailer. keyword-only.
seed: semilla del sampler del detailer. keyword-only.
guide_size: tamano (px) al que se reescala cada cara recortada antes de
re-difundirla (FaceDetailer.guide_size). keyword-only.
bbox_threshold: umbral de confianza del detector de caras (0..1). Mas alto
= menos falsos positivos, riesgo de no detectar caras pequenas.
keyword-only.
feather: pixeles de difuminado del borde de la mascara al recomponer la
cara sobre la imagen. keyword-only.
sampler_name: sampler del detailer (ej. "euler"). keyword-only.
scheduler: scheduler del detailer (ej. "normal"). keyword-only.
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`. En el modo dict
contiene los nodos del workflow base mas los del detailer (con node_ids
prefijados `fd_` para no colisionar); el SaveImage `fd_save` produce la
imagen con las caras regeneradas.
Raises:
ValueError: si se pasa un dict sin `VAEDecode` (no hay fuente de imagen)
o un tipo que no es str ni dict.
"""
bbox_norm = _normalize_bbox_model(bbox_model)
if isinstance(base_workflow_or_image, str):
# Modo imagen: cargar una imagen del input/ y montar un checkpoint nuevo.
base: dict = {}
nodes = {
"fd_load": {
"class_type": "LoadImage",
"inputs": {"image": base_workflow_or_image},
},
"fd_ckpt": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
}
image_in = ["fd_load", 0]
ckpt_id = "fd_ckpt"
elif isinstance(base_workflow_or_image, dict):
# Modo workflow: tomar la imagen del VAEDecode y reutilizar el checkpoint.
base = dict(base_workflow_or_image)
vae_decode_id = _find_first(base, "VAEDecode")
if vae_decode_id is None:
raise ValueError(
"comfyui_build_facedetailer_workflow: el workflow base no tiene "
"VAEDecode; no hay fuente de imagen para el detailer. Pasa el nombre "
"de una imagen (str) o un workflow que decodifique a imagen."
)
image_in = [vae_decode_id, 0]
ckpt_id = _find_first(base, "CheckpointLoaderSimple")
nodes = {}
if ckpt_id is None:
# El base no usa CheckpointLoaderSimple (p.ej. SDXL con otro loader):
# montamos uno propio para el sampler del detailer.
nodes["fd_ckpt"] = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
}
ckpt_id = "fd_ckpt"
else:
raise ValueError(
"comfyui_build_facedetailer_workflow: base_workflow_or_image debe ser "
f"str (nombre de imagen) o dict (workflow), no {type(base_workflow_or_image).__name__}."
)
model = [ckpt_id, 0]
clip = [ckpt_id, 1]
vae = [ckpt_id, 2]
nodes.update(
{
"fd_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": clip},
},
"fd_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": clip},
},
"fd_det": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {"model_name": bbox_norm},
},
"fd_face": {
"class_type": "FaceDetailer",
"inputs": {
"image": image_in,
"model": model,
"clip": clip,
"vae": vae,
"positive": ["fd_pos", 0],
"negative": ["fd_neg", 0],
"bbox_detector": ["fd_det", 0],
"guide_size": guide_size,
"guide_size_for": True,
"max_size": 1024.0,
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"feather": feather,
"noise_mask": True,
"force_inpaint": True,
"bbox_threshold": bbox_threshold,
"bbox_dilation": 10,
"bbox_crop_factor": 3.0,
"sam_detection_hint": "center-1",
"sam_dilation": 0,
"sam_threshold": 0.93,
"sam_bbox_expansion": 0,
"sam_mask_hint_threshold": 0.7,
"sam_mask_hint_use_negative": "False",
"drop_size": 10,
"wildcard": "",
"cycle": 1,
},
},
"fd_save": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["fd_face", 0]},
},
}
)
return {**base, **nodes}
if __name__ == "__main__":
import json
# Modo imagen: regenerar caras de una imagen ya en el input/ del servidor.
wf = comfyui_build_facedetailer_workflow(
"portrait_00001_.png",
ckpt_name="dreamshaper_8.safetensors",
positive="detailed face, sharp eyes, skin texture",
negative="blurry, deformed",
seed=42,
)
print(json.dumps(wf, indent=2))
+73
View File
@@ -0,0 +1,73 @@
---
name: comfyui_build_grid
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_build_grid(image_paths: list, *, cols: int | None = None, cell: int = 512, out_path: str | None = None, labels: list | None = None) -> dict"
description: "Monta un grid / contact-sheet PIL de N imagenes para comparacion visual (p.ej. el output de comfyui_batch_generate con varios seeds). Cada celda conserva el aspect ratio (thumbnail centrado sobre fondo oscuro); rejilla casi cuadrada por defecto (cols=ceil(sqrt(N))). Rotulos opcionales por celda. Usa PIL (Pillow) del venv del registry. Devuelve {ok, out_path, rows, cols, error}. Impura: lee N imagenes y escribe un PNG."
tags: [comfyui, ml, grid, montage, pil, image]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: image_paths
desc: "lista de rutas a las imagenes a montar, en orden de lectura (izq->der, arriba->abajo)."
- name: cols
desc: "numero de columnas; si None usa ceil(sqrt(N)) para una rejilla casi cuadrada."
- name: cell
desc: "lado en pixeles de cada celda cuadrada; la imagen se reduce para caber conservando proporcion (default 512)."
- name: out_path
desc: "ruta del PNG de salida; si None escribe 'comfy_grid.png' en el dir de la primera imagen."
- name: labels
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_grid.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_grid import comfyui_build_grid
imgs = [
os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00002_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00003_.png"),
os.path.expanduser("~/ComfyUI/output/comfy_00004_.png"),
]
res = comfyui_build_grid(imgs, cols=2, cell=512, out_path="/tmp/seeds_grid.png",
labels=["seed 1", "seed 2", "seed 3", "seed 4"])
# {'ok': True, 'out_path': '/tmp/seeds_grid.png', 'rows': 2, 'cols': 2, 'error': ''}
```
## Cuando usarla
Tras un barrido de seeds con `comfyui_batch_generate` + `comfyui_fetch_output_image`:
en vez de abrir N PNGs uno a uno, montas un unico contact-sheet para elegir de un
vistazo la mejor variante (o comparar steps/cfg/sampler distintos). Tambien sirve
para documentar un report con una rejilla de resultados. Es post-proceso local
puro de imagen: no toca el servidor ComfyUI.
## Gotchas
- Si alguna ruta de `image_paths` no existe, devuelve `ok=False` con la lista de
faltantes (estricto): no monta una rejilla parcial silenciosamente. Filtra las
rutas validas antes si quieres tolerar ausencias.
- Cada imagen se reduce a `cell` px conservando proporcion (thumbnail); imagenes de
distinto tamano quedan centradas en su celda con relleno, no estiradas.
- `labels` se dibuja con la fuente por defecto de PIL (pequeña, sin TTF externo);
para rotulos grandes habria que pasar una fuente — no soportado hoy (KISS).
- Escribe el PNG en disco: si `out_path` apunta a un directorio inexistente lo crea;
si no tiene permiso devuelve `ok=False` con el error.
- N grande con `cell` alto produce un canvas enorme (rows*cols*cell^2 px): para
decenas de imagenes baja `cell` (p.ej. 256) para no agotar memoria.
+114
View File
@@ -0,0 +1,114 @@
"""Monta un grid / contact-sheet PIL de N imagenes para comparacion visual.
Funcion impura: lee N imagenes de disco y escribe un PNG de salida. Usa PIL
(Pillow), presente en el venv del registry.
El compañero natural de comfyui_batch_generate: ese encola N variantes de un
workflow (una por seed) pero no junta los resultados. Esta funcion toma las N
imagenes ya descargadas (p.ej. con comfyui_fetch_output_image) y las dispone en
una rejilla regular para compararlas de un vistazo. Cada celda conserva el aspect
ratio (thumbnail centrado sobre fondo oscuro). Opcionalmente rotula cada celda.
"""
import math
import os
def comfyui_build_grid(
image_paths: list,
*,
cols: int | None = None,
cell: int = 512,
out_path: str | None = None,
labels: list | None = None,
) -> dict:
"""Compone una rejilla de imagenes y la guarda como PNG.
Args:
image_paths: lista de rutas a las imagenes (PNG/JPG/...) a montar, en
orden de lectura (izquierda->derecha, arriba->abajo).
cols: numero de columnas; si None se usa ceil(sqrt(N)) para una rejilla
casi cuadrada. keyword-only.
cell: lado en pixeles de cada celda cuadrada; cada imagen se reduce para
caber dentro conservando su proporcion. keyword-only.
out_path: ruta del PNG de salida; si None se escribe "comfy_grid.png" en
el directorio de la primera imagen. keyword-only.
labels: rotulos opcionales, uno por imagen (mismo orden); si se pasan, se
reserva una franja bajo cada celda y se dibuja el texto. keyword-only.
Returns:
dict con:
- ok (bool): True si el grid se monto y guardo.
- out_path (str): ruta del PNG generado.
- rows (int): filas de la rejilla.
- cols (int): columnas de la rejilla.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {"ok": False, "out_path": "", "rows": 0, "cols": 0, "error": ""}
if not image_paths:
out["error"] = "image_paths vacio: nada que montar"
return out
try:
from PIL import Image, ImageDraw
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
missing = [p for p in image_paths if not os.path.isfile(p)]
if missing:
out["error"] = f"no existen {len(missing)} rutas: {missing[:5]}"
return out
n = len(image_paths)
cols = int(cols) if cols and cols > 0 else max(1, math.ceil(math.sqrt(n)))
rows = math.ceil(n / cols)
cell = max(16, int(cell))
label_h = 22 if labels else 0
bg = (24, 24, 28)
fg = (232, 232, 236)
canvas = Image.new("RGB", (cols * cell, rows * (cell + label_h)), bg)
draw = ImageDraw.Draw(canvas) if labels else None
try:
for i, path in enumerate(image_paths):
with Image.open(path) as src:
im = src.convert("RGB")
im.thumbnail((cell, cell))
r, c = divmod(i, cols)
x = c * cell + (cell - im.width) // 2
y = r * (cell + label_h) + (cell - im.height) // 2
canvas.paste(im, (x, y))
if draw is not None and i < len(labels):
tx = c * cell + 4
ty = r * (cell + label_h) + cell + 3
draw.text((tx, ty), str(labels[i]), fill=fg)
except OSError as exc:
out["error"] = f"no se pudo leer/decodificar una imagen: {exc}"
return out
if out_path is None:
out_path = os.path.join(os.path.dirname(os.path.abspath(image_paths[0])),
"comfy_grid.png")
try:
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
canvas.save(out_path)
except OSError as exc:
out["error"] = f"no se pudo escribir {out_path!r}: {exc}"
return out
out.update(ok=True, out_path=out_path, rows=rows, cols=cols)
return out
if __name__ == "__main__":
import json
import sys
paths = sys.argv[1:]
if not paths:
print("uso: comfyui_build_grid.py <img1> <img2> ...", file=sys.stderr)
sys.exit(2)
res = comfyui_build_grid(paths, out_path="/tmp/comfy_grid.png")
print(json.dumps(res, indent=2))
@@ -0,0 +1,104 @@
---
name: comfyui_build_hires_fix_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_hires_fix_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, first_pass: tuple[int, int] = (768, 768), upscale_by: float = 1.5, denoise: float = 0.4, steps: int = 20, cfg: float = 7.0, seed: int = 0, upscale_model: str = \"4x_foolhardy_Remacri.pth\", sampler_name: str = \"euler\", scheduler: str = \"normal\", tile_width: int = 512, tile_height: int = 512, filename_prefix: str = \"hires\") -> dict"
description: "Construye un workflow ComfyUI de hires-fix de 2 pasadas en API format: genera una imagen base pequena (KSampler) y la amplia re-difundiendola por tiles con UltimateSDUpscale + un modelo de upscale (Remacri), anadiendo detalle real a alta resolucion. UltimateSDUpscale es la segunda pasada de muestreo (recibe model/positive/negative/vae). Distinto de comfyui_build_upscale_workflow, que es ESRGAN puro sin re-difusion. Class_types verificados en /object_info. Pura, sin red ni I/O."
tags: [comfyui, ml, hires-fix, ultimatesdupscale, upscale, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Checkpoint tal como lo ve el servidor (CheckpointLoaderSimple)."
- name: positive
desc: "Prompt positivo (se usa en la base y en la re-difusion tiled)."
- name: negative
desc: "Prompt negativo. Por defecto ''."
- name: first_pass
desc: "(ancho, alto) en px de la pasada base (latente pequeno y rapido). Por defecto (768, 768). keyword-only."
- name: upscale_by
desc: "Factor de ampliacion de UltimateSDUpscale sobre la imagen base (1.5 -> 768 pasa a 1152). keyword-only."
- name: denoise
desc: "Fuerza de re-difusion de la segunda pasada (0.4 por defecto). <1 conserva la composicion base y solo anade detalle; 1.0 la re-generaria entera. keyword-only."
- name: steps
desc: "Pasos de sampling (ambas pasadas). keyword-only."
- name: cfg
desc: "Classifier-free guidance (ambas pasadas). keyword-only."
- name: seed
desc: "Semilla de la pasada base (UltimateSDUpscale usa la misma). keyword-only."
- name: upscale_model
desc: "Modelo de upscale en models/upscale_models/ que usa UltimateSDUpscale para escalar antes de re-difundir (ej. '4x_foolhardy_Remacri.pth'). keyword-only."
- name: sampler_name
desc: "Sampler (ambas pasadas). keyword-only."
- name: scheduler
desc: "Scheduler (ambas pasadas). keyword-only."
- name: tile_width
desc: "Ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos = menos VRAM, mas costuras. keyword-only."
- name: tile_height
desc: "Alto de tile de UltimateSDUpscale (px). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids: '4' CheckpointLoaderSimple, '5' EmptyLatentImage, '6'/'7' CLIPTextEncode, '3' KSampler (base), '8' VAEDecode, '11' UpscaleModelLoader, '12' UltimateSDUpscale, '9' SaveImage."
tested: true
tests: ["cadena base (KSampler) + UltimateSDUpscale + SaveImage", "denoise de la 2a pasada <1 (re-difusion parcial)", "first_pass refleja width/height en EmptyLatentImage", "upscale_model llega a UpscaleModelLoader", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_hires_fix_workflow.py"
file_path: "python/functions/ml/comfyui_build_hires_fix_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_hires_fix_workflow import comfyui_build_hires_fix_workflow
wf = comfyui_build_hires_fix_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a fox in a forest, intricate detail, sharp focus",
negative="blurry, low quality",
first_pass=(768, 768),
upscale_by=1.5,
denoise=0.4,
seed=42,
)
# wf["3"]["class_type"] == "KSampler" # pasada base
# wf["12"]["class_type"] == "UltimateSDUpscale" # pasada de detalle (re-difusion)
# wf["12"]["inputs"]["denoise"] == 0.4 # <1 = solo anade detalle
# wf["11"]["inputs"]["model_name"] == "4x_foolhardy_Remacri.pth"
```
O lanzable directo con: `./fn run comfyui_build_hires_fix_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Cuando una imagen a baja resolución se ve plana o sin detalle y quieres una
versión grande y nítida que el modelo "redibuja" en alta (no un simple escalado).
Es el "hires fix" idiomático: genera la base pequeña y rápida, luego añade detalle
real al ampliar. Úsala cuando `comfyui_build_upscale_workflow` (ESRGAN puro) se
queda corto porque no inventa detalle nuevo. Después: `comfyui_submit_workflow`
`comfyui_wait_result``comfyui_fetch_output_image`.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI.
- Requiere el custom node **UltimateSDUpscale** (`comfyui_ultimatesdupscale`). Si
el server responde HTTP 400 "node type not found: UltimateSDUpscale", el custom
node no está cargado.
- El `upscale_model` debe existir en `models/upscale_models/` (aquí
`4x_foolhardy_Remacri.pth`). Sin él, el server rechaza el workflow al encolar.
- **2 etapas de muestreo, 1 KSampler explícito**: UltimateSDUpscale re-samplea
cada tile internamente (por eso recibe `model`/`positive`/`negative`/`vae`), así
que el grafo tiene el KSampler base + el UltimateSDUpscale, no dos KSampler.
- `denoise` de la 2ª pasada controla cuánto cambia: 0.30.45 añade detalle sin
alterar la composición; >0.6 puede deformar caras o introducir artefactos.
- `upscale_by` alto + `tile_width/height` grandes = más VRAM. En 8 GB conviene
tiles de 512 y `upscale_by` 1.52.0.
- Coste real: la 2ª pasada re-difunde N tiles, es bastante más lenta que un upscale
ESRGAN puro. Para solo agrandar sin re-difusión usa `comfyui_build_upscale_workflow`.
@@ -0,0 +1,167 @@
"""Construye un workflow ComfyUI de "hires fix" de 2 pasadas en API format.
El hires fix clasico genera una imagen pequena nitida y luego la amplia
*re-difundiendola* (no solo escalando pixeles), de modo que el modelo anade
detalle coherente a la resolucion alta. Este builder lo implementa con
UltimateSDUpscale (custom node), que hace la segunda pasada por TILES con un
modelo de upscale (ESRGAN/Remacri) + un sampler con `denoise` parcial:
Pasada 1 (base): CheckpointLoaderSimple -> CLIPTextEncode(+/-) +
EmptyLatentImage(first_pass) -> KSampler -> VAEDecode
Pasada 2 (detalle): UpscaleModelLoader(Remacri) +
UltimateSDUpscale(image, model, +/-, vae, upscale_model,
upscale_by, denoise<1, tiled) -> SaveImage
UltimateSDUpscale ES la segunda pasada de muestreo: re-samplea cada tile con el
checkpoint (de ahi que reciba `model`, `positive`, `negative`, `vae`), por eso el
grafo tiene UN KSampler explicito (la base) + el UltimateSDUpscale (el detalle).
Distinto de `comfyui_build_upscale_workflow`, que es ESRGAN puro SIN re-difusion.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
from __future__ import annotations
def comfyui_build_hires_fix_workflow(
ckpt_name: str,
positive: str,
negative: str = "",
*,
first_pass: tuple[int, int] = (768, 768),
upscale_by: float = 1.5,
denoise: float = 0.4,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
upscale_model: str = "4x_foolhardy_Remacri.pth",
sampler_name: str = "euler",
scheduler: str = "normal",
tile_width: int = 512,
tile_height: int = 512,
filename_prefix: str = "hires",
) -> dict:
"""Construye el dict de un workflow hires-fix (base + UltimateSDUpscale).
Args:
ckpt_name: checkpoint tal como lo ve el servidor (CheckpointLoaderSimple).
positive: prompt positivo (se usa en la base y en la re-difusion tiled).
negative: prompt negativo. Por defecto "".
first_pass: (ancho, alto) en px de la pasada base (latente pequeno y
rapido). Por defecto (768, 768). keyword-only.
upscale_by: factor de ampliacion de UltimateSDUpscale sobre la imagen base
(1.5 -> 768 pasa a 1152). keyword-only.
denoise: fuerza de re-difusion de la segunda pasada (0.4 por defecto).
<1 para conservar la composicion base y solo anadir detalle; 1.0 la
re-generaria entera. keyword-only.
steps: pasos de sampling (ambas pasadas). keyword-only.
cfg: classifier-free guidance (ambas pasadas). keyword-only.
seed: semilla de la pasada base (UltimateSDUpscale usa la misma).
keyword-only.
upscale_model: modelo de upscale en models/upscale_models/ que usa
UltimateSDUpscale para escalar antes de re-difundir (ej.
"4x_foolhardy_Remacri.pth"). keyword-only.
sampler_name: sampler (ambas pasadas). keyword-only.
scheduler: scheduler (ambas pasadas). keyword-only.
tile_width: ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos =
menos VRAM, mas costuras. keyword-only.
tile_height: alto de tile de UltimateSDUpscale (px). keyword-only.
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`. node_ids:
"4" CheckpointLoaderSimple, "5" EmptyLatentImage, "6"/"7" CLIPTextEncode,
"3" KSampler (base), "8" VAEDecode, "11" UpscaleModelLoader,
"12" UltimateSDUpscale, "9" SaveImage.
"""
w, h = first_pass
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": w, "height": h, "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]},
},
"11": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": upscale_model},
},
"12": {
"class_type": "UltimateSDUpscale",
"inputs": {
"image": ["8", 0],
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"vae": ["4", 2],
"upscale_model": ["11", 0],
"upscale_by": upscale_by,
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"mode_type": "Linear",
"tile_width": tile_width,
"tile_height": tile_height,
"mask_blur": 8,
"tile_padding": 32,
"seam_fix_mode": "None",
"seam_fix_denoise": 1.0,
"seam_fix_width": 64,
"seam_fix_mask_blur": 8,
"seam_fix_padding": 16,
"force_uniform_tiles": True,
"tiled_decode": False,
},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["12", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_hires_fix_workflow(
ckpt_name="dreamshaper_8.safetensors",
positive="a fox in a forest, intricate detail, sharp focus",
negative="blurry, low quality",
first_pass=(768, 768),
upscale_by=1.5,
denoise=0.4,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,112 @@
---
name: comfyui_build_image_to_3d_workflow
kind: function
lang: py
domain: ml
version: "1.1.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\", watertight: bool = False) -> 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 | VoxelToMesh surface-net si watertight=True) -> 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 del nodo voxel->malla (marching cubes / surface net 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."
- name: watertight
desc: "Si False (default, retro-compatible) el nodo '8' es VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con algorithm='surface net', que produce una malla estanca/manifold de raiz sin post-proceso. 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. El nodo '8' es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net (watertight=True)."
tested: true
tests: ["cadena de 9 nodos Hunyuan3D-2 nativos", "imagen, checkpoint, seed reflejados y SaveGLB presente", "watertight=True usa VoxelToMesh surface-net; default conserva VoxelToMeshBasic", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py"
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.
- Con el default (`watertight=False`) el nodo `VoxelToMeshBasic` produce malla NO
estanca ("cube-soup"), lo esperable; se arregla a posteriori con
`comfyui_make_watertight` o el pipeline `comfyui_mesh_cleanup_oneshot`. Para
malla estanca DE RAÍZ pasa `watertight=True`: usa `VoxelToMesh` con
`algorithm="surface net"` (manifold cerrado sin reparar, ver report 0088). El
nodo `VoxelToMesh` es nativo de ComfyUI >= 0.26.0 (`nodes_hunyuan3d.py`); en
versiones sin él, usar el default + post-proceso.
- `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
(`comfyui_simplify_mesh` / `comfyui_mesh_cleanup_oneshot`).
## Capability growth log
- v1.1.0 (2026-06-24) — añade `watertight=False` (keyword-only, retro-compatible):
con `True` el nodo voxel→malla usa `VoxelToMesh` (`algorithm="surface net"`) en
vez de `VoxelToMeshBasic`, para mallas estancas de raíz sin post-proceso. El
default conserva el comportamiento histórico exacto.
@@ -0,0 +1,163 @@
"""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 | VoxelToMesh) -> SaveGLB
El paso voxel->malla depende del parametro `watertight`:
- watertight=False (default): VoxelToMeshBasic, el comportamiento historico
(marching cubes simple; malla NO estanca, "cube-soup", que luego se arregla con
comfyui_make_watertight).
- watertight=True: VoxelToMesh con algorithm="surface net" (verificado en
/object_info, nodes_hunyuan3d.py), que produce una malla manifold/estanca de
raiz, sin post-proceso (ver report 0088).
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",
watertight: bool = False,
) -> 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 del nodo voxel->malla (marching cubes
/ surface net 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.
watertight: si False (default, retro-compatible) el nodo "8" es
VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con
algorithm="surface net", que produce una malla estanca/manifold de
raiz sin post-proceso. 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. El nodo "8"
es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net
(watertight=True).
"""
voxel_node = (
{
"class_type": "VoxelToMesh",
"inputs": {
"voxel": ["7", 0],
"algorithm": "surface net",
"threshold": threshold,
},
}
if watertight
else {
"class_type": "VoxelToMeshBasic",
"inputs": {"voxel": ["7", 0], "threshold": threshold},
}
)
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": voxel_node,
"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: true
tests: ["usa VAEEncode/LoadImage y no EmptyLatentImage", "denoise e init_image reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2img_workflow.py"
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: true
tests: ["usa LoadImageMask+VAEEncodeForInpaint", "imagen base, mascara, seed y denoise reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_workflow.py"
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: true
tests: ["dos KSamplerAdvanced encadenados", "base emite ruido sobrante y refiner lo recoge (start/end_at_step compartidos)", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_sdxl_refiner_workflow.py"
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,86 @@
---
name: comfyui_build_textured_3d_multiview_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_textured_3d_multiview_workflow(image_name: str, *, ckpt: str = \"hunyuan3d-dit-v2-mv.safetensors\", views: int = 6, octree: int = 384, max_faces: int = 50000, upscale_model: str = \"4x_foolhardy_Remacri.pth\") -> dict"
description: "Construye el dict (API format) del pipeline imagen->malla 3D texturizada PBR multi-vista de ComfyUI via el wrapper Hunyuan3DWrapper (kijai). Cadena: LoadImage -> Hy3DModelLoader -> Hy3DGenerateMesh -> Hy3DVAEDecode(octree) -> Hy3DPostprocessMesh(max_faces) -> Hy3DMeshUVWrap -> Hy3DCameraConfig(4 o 6 vistas) + Hy3DRenderMultiView + Hy3DDelightImage -> Hy3DSampleMultiView -> [UpscaleModelLoader+ImageUpscaleWithModel(Remacri)+ImageResize+] -> Hy3DBakeFromMultiview -> Hy3DMeshVerticeInpaintTexture -> Hy3DApplyTexture -> Hy3DExportMesh(glb). Portado del report 0082 (cobertura de atlas 32.93% con 6 vistas + Remacri + octree 384). Pura, sin red ni I/O."
tags: [comfyui, ml, img-to-3d, texture, multiview, hunyuan3d, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: image_name
desc: "Nombre del archivo de imagen de referencia tal como lo ve el servidor ComfyUI en su carpeta input/ (subido con POST /upload/image)."
- name: ckpt
desc: "Checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader. Por defecto el variante multi-vista hunyuan3d-dit-v2-mv. keyword-only."
- name: views
desc: "Numero de vistas de camara: 4 (front/left/back/right) o 6 (anade top/bottom, rellena concavidades). Otro valor lanza ValueError. keyword-only."
- name: octree
desc: "octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina, mas VRAM; 384 en el report 0082). keyword-only."
- name: max_faces
desc: "max_facenum del Hy3DPostprocessMesh (decimacion; 50000 en el report 0082). keyword-only."
- name: upscale_model
desc: "Modelo de upscale ESRGAN en upscale_models/ para mejorar las vistas antes del bake (factor dominante de cobertura). Cadena vacia desactiva el upscale. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids '1'..'19'; los nodos de upscale ('13'..'15') solo presentes si upscale_model esta activo. El SaveGLB-equivalente Hy3DExportMesh produce un .glb texturizado en output/3D/."
tested: true
tests: ["estructura completa shape+paint+upscale (18 class_types)", "params imagen/ckpt/octree/max_faces reflejados", "6 vistas configuran 6 azimuths/elevations", "4 vistas configuran 4 azimuths", "sin upscale omite nodos Remacri y el bake toma del sample", "views invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py"
file_path: "python/functions/ml/comfyui_build_textured_3d_multiview_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_textured_3d_multiview_workflow import (
comfyui_build_textured_3d_multiview_workflow,
)
wf = comfyui_build_textured_3d_multiview_workflow(
"tex_src_character.png", views=6, octree=384, max_faces=50000,
upscale_model="4x_foolhardy_Remacri.pth",
)
# wf["9"]["class_type"] == "Hy3DCameraConfig" (6 vistas)
# wf["19"]["class_type"] == "Hy3DExportMesh" (.glb texturizado)
# OJO: en 8GB ejecutar en 2 fases (ver Gotchas), no de una pasada
```
O lanzable directo con: `./fn run comfyui_build_textured_3d_multiview_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
Cuando quieras una malla 3D **con textura** desde una sola imagen, con mejor
cobertura de atlas que el image-to-3D nativo (que da geometria sin pintar). Es el
builder del pipeline de texturizado multi-vista del report 0082: 6 vistas de
camara + delight + sample multi-vista + upscale Remacri de las vistas + bake sobre
el UV. Para geometria sin textura usa `comfyui_build_image_to_3d_workflow`
(nodos nativos, mas ligero).
## Gotchas
- **Ejecutar en 2 fases en 8GB**: el grafo es monolitico (shape + paint en un
dict) por claridad, pero el grafo entero da OOM en 8GB (confirmado reports
0075/0081/0082). El camino valido es: ejecutar la fase shape (nodos 1-5 ->
Hy3DExportMesh del shape), liberar VRAM con `POST /free`, y luego la fase paint
arrancando desde `Hy3DLoadMesh` del .glb del shape. La separacion + el /free los
orquesta el pipeline impuro que consuma este builder; este dict es la referencia
de cableado completo.
- Requiere el custom node **ComfyUI-Hunyuan3DWrapper** (kijai) + `custom_rasterizer`
CUDA compilado, **ComfyUI_essentials** (para `ImageResize+`) y el modelo
`4x_foolhardy_Remacri.pth` en `upscale_models/`. Si falta algo, ComfyUI rechaza
el workflow con HTTP 400 (esta funcion es pura y no valida contra el servidor).
- `ckpt` por defecto es el variante multi-vista (`-mv`). El report 0082 uso
`hy3dgen/hunyuan3d-dit-v2-0-fp16.safetensors`; ajusta `ckpt` al nombre real que
el servidor enumere en Hy3DModelLoader.
- `upscale_model=""` desactiva el upscale: el bake toma las vistas directas del
Hy3DSampleMultiView. Pierde la mejora dominante de cobertura (el report midio
20.81% -> 32.93% al cablear Remacri en serie).
- Render bonito del GLB no disponible headless; verificar con `Load3D`/`Preview3D`
en la UI de ComfyUI o el visor de `apps/img_to_3d_webapp`.
@@ -0,0 +1,241 @@
"""Construye un workflow ComfyUI imagen->malla 3D texturizada multi-vista (API format).
Usa el wrapper ComfyUI-Hunyuan3DWrapper (kijai): genera la geometria con
Hy3DGenerateMesh/Hy3DVAEDecode, la limpia y le hace UV unwrap, renderiza N vistas
de camara, sintetiza la textura multi-vista (Hy3DSampleMultiView) opcionalmente
mejorada con un upscaler ESRGAN (Remacri), la hornea sobre el atlas UV
(Hy3DBakeFromMultiview), rellena los huecos por vertices y exporta el GLB con
material PBR. Portado del pipeline validado en el report 0082 (cobertura de atlas
32.93 % con 6 vistas + Remacri + octree 384).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
IMPORTANTE: el grafo es monolitico (shape + paint en un solo dict) por claridad,
pero en 8 GB de VRAM debe ejecutarse en 2 fases (shape -> /free -> paint), no de
una pasada. La separacion en fases y el /free los orquesta el pipeline impuro que
consuma este builder. Ver la seccion Gotchas del .md.
"""
# Vistas de camara soportadas: tabla (azimuths, elevations, weights) por numero de vistas.
# 4 = front/left/back/right; 6 anade top/bottom (rellena concavidades que 4 camaras no ven).
_CAMERA_PRESETS = {
4: {
"camera_azimuths": "0, 90, 180, 270",
"camera_elevations": "0, 0, 0, 0",
"view_weights": "1, 0.1, 0.5, 0.1",
},
6: {
"camera_azimuths": "0, 90, 180, 270, 0, 180",
"camera_elevations": "0, 0, 0, 0, 90, -90",
"view_weights": "1, 0.1, 0.5, 0.1, 0.05, 0.05",
},
}
def comfyui_build_textured_3d_multiview_workflow(
image_name: str,
*,
ckpt: str = "hunyuan3d-dit-v2-mv.safetensors",
views: int = 6,
octree: int = 384,
max_faces: int = 50000,
upscale_model: str = "4x_foolhardy_Remacri.pth",
) -> dict:
"""Construye el dict del workflow imagen->3D texturizado multi-vista.
Args:
image_name: nombre del archivo de imagen de referencia tal como lo ve el
servidor ComfyUI en su carpeta input/ (subido con POST /upload/image).
ckpt: checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader (por
defecto el variante multi-vista hunyuan3d-dit-v2-mv). keyword-only.
views: numero de vistas de camara: 4 (front/left/back/right) o 6 (anade
top/bottom). Cualquier otro valor lanza ValueError. keyword-only.
octree: octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina,
mas VRAM). keyword-only.
max_faces: max_facenum del Hy3DPostprocessMesh (decimacion de la malla).
keyword-only.
upscale_model: nombre del modelo de upscale ESRGAN en upscale_models/ para
mejorar las vistas antes del bake. Cadena vacia o None desactiva el
upscale (el bake toma las vistas directas del sample). keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. node_ids "1".."19"
(los de upscale "13".."15" solo presentes si upscale_model esta activo).
Raises:
ValueError: si views no es 4 ni 6.
"""
if views not in _CAMERA_PRESETS:
raise ValueError(
f"comfyui_build_textured_3d_multiview_workflow: views debe ser 4 o 6, "
f"no {views!r}"
)
cam = _CAMERA_PRESETS[views]
wf = {
# --- Fase shape: imagen -> malla limpia con UV ---
"1": {
"class_type": "LoadImage",
"inputs": {"image": image_name},
},
"2": {
"class_type": "Hy3DModelLoader",
"inputs": {"model": ckpt, "attention_mode": "sdpa", "cublas_ops": False},
},
"3": {
"class_type": "Hy3DGenerateMesh",
"inputs": {
"pipeline": ["2", 0],
"image": ["1", 0],
"guidance_scale": 5.5,
"steps": 30,
"seed": 42,
"force_offload": True,
},
},
"4": {
"class_type": "Hy3DVAEDecode",
"inputs": {
"vae": ["2", 1],
"latents": ["3", 0],
"box_v": 1.01,
"octree_resolution": octree,
"num_chunks": 8000,
"mc_level": 0,
"mc_algo": "mc",
"enable_flash_vdm": True,
"force_offload": True,
},
},
"5": {
"class_type": "Hy3DPostprocessMesh",
"inputs": {
"trimesh": ["4", 0],
"remove_floaters": True,
"remove_degenerate_faces": True,
"reduce_faces": True,
"max_facenum": max_faces,
"smooth_normals": False,
},
},
"6": {
"class_type": "Hy3DMeshUVWrap",
"inputs": {"trimesh": ["5", 0]},
},
# --- Fase paint: render multi-vista + delight + sample + bake + textura ---
"7": {
"class_type": "DownloadAndLoadHy3DPaintModel",
"inputs": {"model": "hunyuan3d-paint-v2-0"},
},
"8": {
"class_type": "DownloadAndLoadHy3DDelightModel",
"inputs": {"model": "hunyuan3d-delight-v2-0"},
},
"9": {
"class_type": "Hy3DCameraConfig",
"inputs": {
"camera_azimuths": cam["camera_azimuths"],
"camera_elevations": cam["camera_elevations"],
"view_weights": cam["view_weights"],
"camera_distance": 1.45,
"ortho_scale": 1.2,
},
},
"10": {
"class_type": "Hy3DRenderMultiView",
"inputs": {
"trimesh": ["6", 0],
"render_size": 1024,
"texture_size": 1024,
"camera_config": ["9", 0],
"normal_space": "world",
},
},
"11": {
"class_type": "Hy3DDelightImage",
"inputs": {
"delight_pipe": ["8", 0],
"image": ["1", 0],
"steps": 50,
"width": 512,
"height": 512,
"cfg_image": 1.0,
"seed": 42,
},
},
"12": {
"class_type": "Hy3DSampleMultiView",
"inputs": {
"pipeline": ["7", 0],
"ref_image": ["11", 0],
"normal_maps": ["10", 0],
"position_maps": ["10", 1],
"view_size": 512,
"steps": 25,
"seed": 0,
"camera_config": ["9", 0],
},
},
}
# Upscale opcional de los multiviews antes del bake (factor dominante de cobertura).
if upscale_model:
wf["13"] = {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": upscale_model},
}
wf["14"] = {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
}
wf["15"] = {
"class_type": "ImageResize+",
"inputs": {
"image": ["14", 0],
"width": 1024,
"height": 1024,
"interpolation": "lanczos",
"method": "stretch",
"condition": "always",
"multiple_of": 0,
},
}
bake_images = ["15", 0]
else:
bake_images = ["12", 0]
wf["16"] = {
"class_type": "Hy3DBakeFromMultiview",
"inputs": {
"images": bake_images,
"renderer": ["10", 2],
"camera_config": ["9", 0],
},
}
wf["17"] = {
"class_type": "Hy3DMeshVerticeInpaintTexture",
"inputs": {"texture": ["16", 0], "mask": ["16", 1], "renderer": ["16", 2]},
}
wf["18"] = {
"class_type": "Hy3DApplyTexture",
"inputs": {"texture": ["17", 0], "renderer": ["17", 2]},
}
wf["19"] = {
"class_type": "Hy3DExportMesh",
"inputs": {
"trimesh": ["18", 0],
"filename_prefix": "3D/textured_multiview",
"file_format": "glb",
"save_file": True,
},
}
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_textured_3d_multiview_workflow(
"tex_src_character.png", views=6, octree=384, max_faces=50000
)
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: true
tests: ["class_types esperados (6 nodos)", "params seed/steps/cfg/width/height reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_txt2img_workflow.py"
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: true
tests: ["method='model' usa UpscaleModelLoader+ImageUpscaleWithModel", "method='latent' usa ImageScaleBy sin modelo", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_upscale_workflow.py"
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,90 @@
---
name: comfyui_build_video_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_video_workflow(prompt: str, *, model: str = \"ltx\", negative: str = \"\", width: int = 512, height: int = 320, num_frames: int = 65, steps: int = 20, seed: int = 0, fps: int = 24) -> dict"
description: "Construye el dict de un workflow ComfyUI txt2video en API format para LTX-Video 2B v0.9.5 (model='ltx') o Wan2.1 T2V 1.3B (model='wan'), con los nombres de modelo reales. LTX: CLIPLoader(ltxv)+CheckpointLoaderSimple -> CLIPTextEncode x2 -> LTXVConditioning+EmptyLTXVLatentVideo+LTXVScheduler+KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo. Wan: UNETLoader+CLIPLoader(wan)+VAELoader+ModelSamplingSD3 -> CLIPTextEncode x2+EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) -> VAEDecode -> CreateVideo -> SaveVideo. Defaults conservadores para 8GB. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, ml, video-generation, txt2video, ltx-video, wan, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo: lo que se quiere ver en el clip de video."
- name: model
desc: "'ltx' (LTX-Video 2B v0.9.5, todo-en-uno) o 'wan' (Wan2.1 T2V 1.3B, diffusion+vae aparte). Cualquier otro valor lanza ValueError. keyword-only."
- name: negative
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia. keyword-only."
- name: width
desc: "Ancho del video en px (multiplo de 32 recomendado). keyword-only."
- name: height
desc: "Alto del video en px (multiplo de 32 recomendado). keyword-only."
- name: num_frames
desc: "Numero de frames del clip (longitud temporal del latente de video). keyword-only."
- name: steps
desc: "Pasos de sampling: LTXVScheduler para ltx, KSampler para wan. keyword-only."
- name: seed
desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only."
- name: fps
desc: "Frames por segundo del video (CreateVideo). En LTX se usa tambien como frame_rate del LTXVConditioning. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. LTX devuelve 12 nodos; Wan 11. La cfg/sampler/scheduler se fijan internamente segun el modelo (LTX: cfg 3.0, euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0)."
tested: true
tests: ["LTX: nodos LTXV* presentes + t5xxl fp8 + ckpt real", "Wan: UNETLoader/VAELoader/ModelSamplingSD3 + umt5 + wan_2.1_vae", "params reflejados (width/height/num_frames/steps/seed/fps)", "model invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_video_workflow.py"
file_path: "python/functions/ml/comfyui_build_video_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_video_workflow import comfyui_build_video_workflow
wf = comfyui_build_video_workflow(
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
model="ltx",
negative="low quality, worst quality, deformed, motion smear",
width=512, height=320, num_frames=65, steps=25, seed=42, fps=24,
)
# wf["72"]["class_type"] == "SamplerCustom" (camino LTX)
# wf["79"]["class_type"] == "SaveVideo"
# -> comfyui_submit_workflow(wf) para encolar el clip
```
O lanzable directo con: `./fn run comfyui_build_video_workflow` (imprime el JSON del workflow LTX de ejemplo).
## Cuando usarla
Antes de enviar una generacion de video txt2video a ComfyUI: construye aqui el
dict del workflow y pasalo a `comfyui_submit_workflow`. Usa `model="ltx"` por
defecto (cupo en 8GB confirmado, scheduler y VAE temporales propios); `model="wan"`
si quieres el camino Wan2.1 1.3B (umt5 + vae aparte). Hermana de
`comfyui_build_txt2img_workflow` para imagen estatica.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- Los nombres de modelo estan fijados a los reales del equipo
(`ltx-video-2b-v0.9.5.safetensors` + `t5xxl_fp8_e4m3fn_scaled.safetensors`;
`wan2.1_t2v_1.3B_fp16.safetensors` + `umt5_xxl_fp8_e4m3fn_scaled.safetensors` +
`wan_2.1_vae.safetensors`). Deben existir y ser visibles para el servidor o
ComfyUI rechaza el workflow con HTTP 400 al enviarlo (esta funcion es pura y no
valida contra el servidor).
- Cupo 8GB: con los defaults (512x320, 65 frames) LTX pico ~7.7 GB en el report
0084 sin OOM. Subir resolucion o num_frames acerca el techo. Si da OOM, bajar a
512x288 / 49 frames.
- El camino LTX esta validado de extremo a extremo (report 0084: clip real de 65
frames). El camino Wan modela la plantilla nativa canonica de ComfyUI pero NO se
ejecuto en esa sesion; verificar nombres de modelo antes de tirar de el.
- LTX usa cfg baja (3.0). Subirla degrada el video. Por eso la cfg no es parametro:
se fija segun el modelo.
- `SaveVideo` necesita `format`/`codec` (aqui "auto"/"auto"); sin ellos ComfyUI
responde HTTP 400 (gotcha del importador, report 0084). Este builder ya los pone.
@@ -0,0 +1,232 @@
"""Construye un workflow ComfyUI txt2video en "API format" (dict de nodos numerados).
Soporta dos modelos de difusion de video nativos de ComfyUI 0.26, ambos pensados
para caber en 8 GB de VRAM con parametros conservadores:
- model="ltx": LTX-Video 2B v0.9.5. Checkpoint todo-en-uno (UNet + VAE temporal) +
text encoder t5xxl en fp8. Cadena CLIPLoader(ltxv) + CheckpointLoaderSimple ->
CLIPTextEncode x2 -> LTXVConditioning + EmptyLTXVLatentVideo + LTXVScheduler +
KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo.
Validado de extremo a extremo en el report 0084 (clip real de 65 frames).
- model="wan": Wan2.1 T2V 1.3B. Diffusion model (UNETLoader) + text encoder umt5
fp8 (CLIPLoader type=wan) + wan_2.1_vae aparte (VAELoader) + ModelSamplingSD3 ->
CLIPTextEncode x2 + EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) ->
VAEDecode -> CreateVideo -> SaveVideo. Plantilla nativa canonica de ComfyUI.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
# Nombres reales de los modelos tal como los ve el servidor ComfyUI.
_LTX_CKPT = "ltx-video-2b-v0.9.5.safetensors"
_LTX_CLIP = "t5xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_UNET = "wan2.1_t2v_1.3B_fp16.safetensors"
_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_VAE = "wan_2.1_vae.safetensors"
def comfyui_build_video_workflow(
prompt: str,
*,
model: str = "ltx",
negative: str = "",
width: int = 512,
height: int = 320,
num_frames: int = 65,
steps: int = 20,
seed: int = 0,
fps: int = 24,
) -> dict:
"""Construye el dict del workflow txt2video para LTX-Video 2B o Wan2.1 1.3B.
Args:
prompt: prompt positivo (lo que se quiere ver en el clip).
model: "ltx" (LTX-Video 2B v0.9.5) o "wan" (Wan2.1 T2V 1.3B). keyword-only.
negative: prompt negativo. keyword-only.
width: ancho del video en px (multiplo de 32 recomendado). keyword-only.
height: alto del video en px (multiplo de 32 recomendado). keyword-only.
num_frames: numero de frames del clip (longitud temporal del latente).
keyword-only.
steps: pasos de sampling (LTXVScheduler para ltx, KSampler para wan).
keyword-only.
seed: semilla del sampler (0 = determinista; cambiar para variar).
keyword-only.
fps: frames por segundo del video resultante (CreateVideo). En LTX se usa
ademas como frame_rate del condicionamiento LTXVConditioning.
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids (string) y cada valor tiene class_type + inputs. La cfg, el
sampler y el scheduler se fijan internamente segun el modelo (LTX: cfg 3.0,
euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0).
Raises:
ValueError: si model no es "ltx" ni "wan".
"""
m = model.lower()
if m == "ltx":
return {
"38": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": _LTX_CLIP, "type": "ltxv", "device": "default"},
},
"44": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": _LTX_CKPT},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["38", 0]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["38", 0]},
},
"70": {
"class_type": "EmptyLTXVLatentVideo",
"inputs": {
"width": width,
"height": height,
"length": num_frames,
"batch_size": 1,
},
},
"71": {
"class_type": "LTXVScheduler",
"inputs": {
"steps": steps,
"max_shift": 2.05,
"base_shift": 0.95,
"stretch": True,
"terminal": 0.1,
"latent": ["70", 0],
},
},
"73": {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
},
"69": {
"class_type": "LTXVConditioning",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"frame_rate": fps,
},
},
"72": {
"class_type": "SamplerCustom",
"inputs": {
"model": ["44", 0],
"positive": ["69", 0],
"negative": ["69", 1],
"sampler": ["73", 0],
"sigmas": ["71", 0],
"latent_image": ["70", 0],
"add_noise": True,
"noise_seed": seed,
"cfg": 3.0,
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["72", 0], "vae": ["44", 2]},
},
"78": {
"class_type": "CreateVideo",
"inputs": {"images": ["8", 0], "fps": fps},
},
"79": {
"class_type": "SaveVideo",
"inputs": {
"video": ["78", 0],
"filename_prefix": "video",
"format": "auto",
"codec": "auto",
},
},
}
if m == "wan":
return {
"37": {
"class_type": "UNETLoader",
"inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"},
},
"38": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": _WAN_CLIP, "type": "wan", "device": "default"},
},
"39": {
"class_type": "VAELoader",
"inputs": {"vae_name": _WAN_VAE},
},
"48": {
"class_type": "ModelSamplingSD3",
"inputs": {"shift": 8.0, "model": ["37", 0]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["38", 0]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["38", 0]},
},
"40": {
"class_type": "EmptyHunyuanLatentVideo",
"inputs": {
"width": width,
"height": height,
"length": num_frames,
"batch_size": 1,
},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": 6.0,
"sampler_name": "uni_pc",
"scheduler": "simple",
"denoise": 1.0,
"model": ["48", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["40", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["39", 0]},
},
"78": {
"class_type": "CreateVideo",
"inputs": {"images": ["8", 0], "fps": fps},
},
"79": {
"class_type": "SaveVideo",
"inputs": {
"video": ["78", 0],
"filename_prefix": "video",
"format": "auto",
"codec": "auto",
},
},
}
raise ValueError(
f"comfyui_build_video_workflow: model debe ser 'ltx' o 'wan', no {model!r}"
)
if __name__ == "__main__":
import json
wf = comfyui_build_video_workflow(
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
model="ltx",
negative="low quality, worst quality, deformed, motion smear",
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,82 @@
---
name: comfyui_build_view_3d_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_view_3d_workflow(model_file: str, *, animation: bool = False, width: int = 1024, height: int = 1024) -> dict"
description: "Construye el dict API-format de un visor 3D minimo de ComfyUI con el nodo nativo Load3D (display 'Load 3D & Animation', comfy_extras.nodes_load_3d, categoria 3d) para VISUALIZAR un GLB/GLTF/OBJ/FBX/STL/PLY existente, orbitando con el raton, sin ejecutar el grafo (no es output node). animation=True usa Load3DAdvanced (input viewport_state, control avanzado de camara); animation=False usa Load3D (input image de estado del visor, el del report 0079). Pura, sin red ni I/O."
tags: [comfyui, ml, view-3d, load3d, mesh, workflow, viewer]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: model_file
desc: "Ruta del modelo RELATIVA al input/ del servidor ComfyUI (ej. '3d/fox_mv_textured.glb'). El archivo debe existir ya bajo ~/ComfyUI/input/3d/ para que el visor lo cargue (Load3D solo lista ese directorio)."
- name: animation
desc: "Si True usa Load3DAdvanced (viewport_state, control avanzado de camara/viewport para inspeccionar modelos animados); si False (default) usa Load3D, el visor estandar. Ambos reproducen animaciones embebidas del modelo en el frontend. keyword-only."
- name: width
desc: "Ancho del viewport del nodo en px. keyword-only."
- name: height
desc: "Alto del viewport del nodo en px. keyword-only."
output: "dict en API format con un unico nodo '1'. Con animation=False: class_type 'Load3D', inputs {model_file, image, width, height}. Con animation=True: class_type 'Load3DAdvanced', inputs {model_file, viewport_state, width, height}. Cargable con comfyui_load_workflow_ui (inyecta en la UI del navegador) o POSTeable a /prompt."
tested: true
tests: ["Load3D simple con model_file/width/height", "animation=True usa Load3DAdvanced", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_view_3d_workflow.py"
file_path: "python/functions/ml/comfyui_build_view_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_view_3d_workflow import comfyui_build_view_3d_workflow
wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb")
# wf == {"1": {"class_type": "Load3D",
# "inputs": {"model_file": "3d/fox_mv_textured.glb", "image": "",
# "width": 1024, "height": 1024}}}
# Inyectar en la UI abierta (visor interactivo, orbita con el raton):
# from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
# comfyui_load_workflow_ui(wf, server_url_substr="8188")
# Variante avanzada (control de camara/viewport):
wf_adv = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True)
# wf_adv["1"]["class_type"] == "Load3DAdvanced"
```
O lanzable directo con: `./fn run comfyui_build_view_3d_workflow` (imprime los dos workflows de ejemplo).
## Cuando usarla
Cuando ya tengas un mesh GLB/OBJ (p.ej. la salida de `comfyui_image_to_3d_oneshot`,
descargada con `comfyui_fetch_output_mesh`) y quieras VERLO con su textura/color dentro
de un nodo de ComfyUI, interactivo. Construye aquí el dict del visor y cárgalo en la UI
con `comfyui_load_workflow_ui`. Es shape+textura: el visor Three.js pinta el material PBR
del GLB (report 0079: el zorro se ve naranja, no gris). Para añadir el nodo SIN reemplazar
el grafo abierto del usuario, el método no-destructivo es inyectarlo vía CDP
(`LiteGraph.createNode('Load3D')` + `app.graph.add`), ver report 0079.
## Gotchas
- **`model_file` debe ser ruta RELATIVA a `input/`** (p.ej. `3d/fox.glb`), y el archivo
debe existir bajo `~/ComfyUI/input/3d/`. `Load3D` solo lista/carga ese directorio: si
el GLB vive en `output/3D/`, cópialo a `input/3d/` antes (eso es I/O, fuera de esta
función pura). Sin la copia el combo `model_file` solo ofrece `none`.
- **No es output node**: `Load3D`/`Load3DAdvanced` renderizan en el frontend (Three.js)
SIN ejecutar el grafo (no hace falta Queue). Si quieres mostrar un GLB que produce un
pipeline al ejecutar, usa `Preview3D` (output node, requiere queue) — no es esta función.
- **Requiere ComfyUI >= 0.26.0** (nodos nativos `Load3D`/`Load3DAdvanced`, módulo
`comfy_extras.nodes_load_3d`). En versiones anteriores el server rechaza el workflow.
- El flag `animation` elige la VARIANTE de nodo, no un modo "play": ambos visores ya
reproducen las animaciones embebidas del modelo en el frontend. `Load3DAdvanced` aporta
`viewport_state` (control fino de cámara), útil para inspeccionar la órbita de un modelo
animado; `Load3D` da además un preview `image` del visor.
- Pura: sólo arma el dict, no toca red ni disco ni valida contra el server. Valida con
`comfyui_validate_workflow` si dudas de que el nodo exista en tu versión.
@@ -0,0 +1,86 @@
"""Construye el workflow minimo de un visor 3D nativo de ComfyUI (Load3D).
ComfyUI 0.26.0 trae el nodo nativo `Load3D` (display "Load 3D & Animation",
`comfy_extras.nodes_load_3d`, categoria `3d`): un visor Three.js embebido que
renderiza un GLB/GLTF/OBJ/FBX/STL/PLY **en el frontend, sin ejecutar el grafo**
(no es output node). Sirve para VER un mesh ya existente con su textura/color,
orbitando con el raton, dentro de un nodo de la UI.
Este builder devuelve el dict API-format (un unico nodo) cargable en la UI con
`comfyui_load_workflow_ui`. La variante se elige con `animation`:
- animation=False -> `Load3D` (visor estandar; input `image` de estado del
visor; el usado en el report 0079). Reproduce animaciones embebidas del
modelo en el frontend.
- animation=True -> `Load3DAdvanced` (display "Load 3D (Advanced)"; input
`viewport_state` en vez de `image`): mismo visor con control avanzado de
camara/viewport, mejor para inspeccionar la orbita de un modelo animado.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
GOTCHA: `Load3D`/`Load3DAdvanced` solo listan/cargan archivos que esten bajo
`~/ComfyUI/input/3d/`. `model_file` debe ser la ruta RELATIVA a `input/`
(p.ej. "3d/fox.glb"). Copiar el GLB ahi es I/O, fuera de esta funcion pura.
"""
def comfyui_build_view_3d_workflow(
model_file: str,
*,
animation: bool = False,
width: int = 1024,
height: int = 1024,
) -> dict:
"""Monta el API-format de un visor 3D minimo para un GLB/GLTF/OBJ existente.
Args:
model_file: ruta del modelo RELATIVA al `input/` del servidor ComfyUI
(p.ej. "3d/fox_mv_textured.glb"). El archivo debe existir ya bajo
`~/ComfyUI/input/3d/` para que el visor lo cargue.
animation: si True usa `Load3DAdvanced` (control avanzado de
camara/viewport, apto para inspeccionar modelos animados); si False
(default) usa `Load3D`, el visor estandar del report 0079. Ambos
reproducen animaciones embebidas del modelo en el frontend.
keyword-only.
width: ancho del viewport del nodo en px. keyword-only.
height: alto del viewport del nodo en px. keyword-only.
Returns:
dict en API format con un unico nodo "1". Con animation=False:
{"1": {"class_type": "Load3D", "inputs": {"model_file", "image",
"width", "height"}}}; con animation=True el class_type es
"Load3DAdvanced" y el segundo input es "viewport_state". Cargable con
comfyui_load_workflow_ui (inyecta en la UI) o POSTeable a /prompt.
"""
if animation:
return {
"1": {
"class_type": "Load3DAdvanced",
"inputs": {
"model_file": model_file,
"viewport_state": "",
"width": width,
"height": height,
},
}
}
return {
"1": {
"class_type": "Load3D",
"inputs": {
"model_file": model_file,
"image": "",
"width": width,
"height": height,
},
}
}
if __name__ == "__main__":
import json
wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb")
print(json.dumps(wf, indent=2))
wf_anim = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True)
print(json.dumps(wf_anim, 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,93 @@
---
name: comfyui_download_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_download_workflow(source: str, dest: str | None = None, *, server: str = \"127.0.0.1:8188\", civitai_token: str | None = None, hf_token: str | None = None, timeout: float = 30.0) -> dict"
description: "Descarga un workflow ComfyUI desde CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Dispatcher que detecta el tipo de fuente por la URL y delega: Drive via gdown/uc?export=download, GitHub via raw.githubusercontent.com, Civitai via API REST (resuelve downloadUrl, descomprime zip), HuggingFace via resolve/. Tras bajar: PNG/WebP -> comfyui_import_workflow_png; JSON -> comfyui_import_workflow_json (normaliza UI->API). Compone import_workflow_json + import_workflow_png. Impura: red + descompresion + disco."
tags: [comfyui, ml, workflow, download, dispatcher, import]
uses_functions: [comfyui_import_workflow_json_py_ml, comfyui_import_workflow_png_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: source
desc: "URL (Google Drive con /d/<id> o ?id=, GitHub blob o raw, Civitai /api/download o /models/<id>, HuggingFace resolve, o URL directa a .json/.png/.webp) o ruta de un archivo local."
- name: dest
desc: "Ruta local donde guardar el archivo descargado. Si None, archivo temporal (se conserva y se reporta en 'path'). Para fuentes locales no copia: path = source. keyword-only por posicion 2 (acepta posicional)."
- name: server
desc: "host:port de ComfyUI, usado SOLO para mapear widgets cuando la fuente viene en formato UI graph (lo pasa a import_workflow_json). keyword-only."
- name: civitai_token
desc: "Token de Civitai (Bearer) para descargas restringidas/gated. keyword-only."
- name: hf_token
desc: "Token de HuggingFace (Bearer) para datasets privados. keyword-only."
- name: timeout
desc: "Timeout HTTP en segundos. keyword-only."
output: "dict {ok, workflow, source_type, path, format_in, error}. workflow = dict API format (vacio si ok=False); source_type = drive|github|civitai|huggingface|direct|local; path = ruta local descargada; format_in = api|ui_graph|png-prompt|png-workflow|zip. Nunca lanza: fallos devuelven ok=False con error."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_download_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_download_workflow import comfyui_download_workflow
# GitHub (cubiq, Apache-2.0) — baja el raw .json y lo deja en API format
res = comfyui_download_workflow(
"https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/main/ComfyUI_Simple/SDXL_simple.json"
)
# res == {"ok": True, "workflow": {...}, "source_type": "github",
# "path": "/tmp/comfy_wf_xxx.json", "format_in": "ui_graph", "error": ""}
# Google Drive por share-url (extrae el file id; usa gdown si esta, si no descarga directa)
res2 = comfyui_download_workflow("https://drive.google.com/file/d/<FILE_ID>/view", dest="/tmp/wf.json")
# El workflow resultante esta listo para validar/encolar:
# from ml.comfyui_validate_workflow import comfyui_validate_workflow
# comfyui_validate_workflow(res["workflow"])
```
Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`. El bloque `__main__` baja el ejemplo de cubiq cuando lo ejecutas como script.
## Cuando usarla
Cuando tengas la URL de un workflow ajeno (Drive de un creador, repo GitHub, página
de Civitai, dataset de HuggingFace) y quieras un dict en API format sin pensar en el
método de descarga ni en el formato. Es el punto de entrada único antes de
`comfyui_validate_workflow` + `comfyui_resolve_workflow_deps` + `comfyui_submit_workflow`.
Para una fuente que ya sabes que es JSON local/URL directa, `comfyui_import_workflow_json`
basta; para un PNG suelto, `comfyui_import_workflow_png`. Este dispatcher es para
"dame el workflow de esta URL, sea cual sea la fuente". Catálogo de fuentes: report 0080.
## Gotchas
- Impura: hace HTTP GET (y gdown/unzip según fuente) + escribe a disco. Cualquier
fallo de red/IO devuelve `{ok: False, error: ...}` (no lanza).
- **Google Drive**: usa `gdown` si está instalado (maneja el aviso de virus-scan de
archivos grandes). Sin gdown cae a `uc?export=download`, que solo sirve para
archivos pequeños (un `.json` de workflow son KB); si Drive devuelve HTML (aviso
de virus-scan o gated) la función pide instalar gdown. `pip install gdown` en el venv.
- **Civitai**: descargas gated/early-access exigen `civitai_token` (Bearer). Sin token
la respuesta puede ser HTML de login → error claro. Una página `/models/<id>` se
resuelve via `/api/v1/models/<id>` tomando el primer file; para precisión pasa el
`downloadUrl` directo (`/api/download/models/<version_id>`).
- **GitHub**: una URL `github.com/.../blob/...` se reescribe a `raw.githubusercontent.com`
automáticamente; si pasas la URL de la página HTML (no raw ni blob) puede bajar HTML
→ error. Mejor pasar el raw o el blob.
- **Formato de salida siempre API**: un PNG con chunk `prompt` (API) se usa directo; si
solo trae el chunk `workflow` (UI graph) se normaliza vía import_workflow_json (necesita
el server vivo para mapear widgets). Un UI graph `.json` se normaliza igual (best-effort:
conexiones siempre; widgets sólo si el server responde).
- **El workflow descargado es un secreto si trae credenciales/cookies** (raro en workflows,
común en HAR): este caso es de workflows públicos; aun así no commitear el `path` temporal.
- Fuentes con anti-bot fuerte (ComfyWorkflows.com, comfy.org/workflows con Cloudflare)
pueden devolver 402/HTML a la descarga directa → requieren navegador (CDP). No cubiertas.
@@ -0,0 +1,326 @@
"""Descarga un workflow ComfyUI desde CUALQUIER fuente y lo normaliza a API format.
Dispatcher: detecta el tipo de fuente por la URL/patron y delega la descarga, luego
normaliza el resultado a API format reusando las dos funciones de import del registry
(no reescribe la conversion):
- Google Drive (drive.google.com/.../d/<id> o uc?id=) -> gdown (si esta) o
descarga directa uc?export=download -> import_workflow_json | import_workflow_png
- GitHub (github.com/.../blob/... o raw.githubusercontent.com) -> raw URL del
.json/.png -> import_workflow_json | import_workflow_png
- Civitai (civitai.com/api/download/... o pagina /models/<id>) -> resuelve el
downloadUrl via API REST, descarga el archivo (zip o json) -> import
- HuggingFace (huggingface.co/datasets/.../resolve/...) -> import_workflow_json
- URL directa .json/.png/.webp o path local -> import segun extension
El resultado SIEMPRE es API format (dict {node_id: {class_type, inputs}}), listo para
comfyui_validate_workflow + comfyui_submit_workflow.
Compone comfyui_import_workflow_json + comfyui_import_workflow_png. Impura: red
(HTTP GET / gdown), descompresion de zip y lectura/escritura de disco. Solo stdlib
(urllib, json, zipfile, tempfile, re) + gdown opcional para Drive.
"""
import json
import os
import re
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from comfyui_import_workflow_json import comfyui_import_workflow_json # noqa: E402
from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402
_UA = "Mozilla/5.0 (fn_registry comfyui_download_workflow)"
def comfyui_download_workflow(
source: str,
dest: str | None = None,
*,
server: str = "127.0.0.1:8188",
civitai_token: str | None = None,
hf_token: str | None = None,
timeout: float = 30.0,
) -> dict:
"""Descarga un workflow de ComfyUI de cualquier fuente y lo normaliza a API format.
Args:
source: URL (Google Drive, GitHub, Civitai, HuggingFace, o directa a
.json/.png/.webp) o ruta de un archivo local.
dest: ruta local donde guardar el archivo descargado. Si None, se usa un
archivo temporal (que se conserva para trazabilidad y se reporta en
'path'). Para fuentes locales no se copia: 'path' = source.
server: host:port de ComfyUI, usado SOLO para mapear widgets cuando la
fuente viene en formato UI graph (lo pasa a import_workflow_json).
keyword-only.
civitai_token: token de Civitai (Bearer) para descargas restringidas/gated.
keyword-only.
hf_token: token de HuggingFace (Bearer) para datasets privados. keyword-only.
timeout: timeout HTTP en segundos. keyword-only.
Returns:
dict {ok, workflow, source_type, path, format_in, error}:
- workflow: dict en API format (vacio si ok=False).
- source_type: 'drive' | 'github' | 'civitai' | 'huggingface' |
'direct' | 'local'.
- path: ruta local del archivo descargado (o source si era local).
- format_in: formato de origen detectado ('api', 'ui_graph',
'png-prompt', 'png-workflow', 'zip').
Nunca lanza: cualquier fallo de red/IO devuelve ok=False con error.
"""
source_type = _detect_source_type(source)
try:
if source_type == "local":
local_path = source
if not os.path.exists(local_path):
return _err(source_type, f"no existe el archivo local {source!r}")
elif source_type == "drive":
local_path = _download_drive(source, dest, timeout)
elif source_type == "civitai":
local_path = _download_civitai(source, dest, civitai_token, timeout)
else: # github | huggingface | direct
url = _to_raw_url(source) if source_type == "github" else source
token = hf_token if source_type == "huggingface" else None
local_path = _download_url(url, dest, token, timeout)
except _DownloadError as exc:
return _err(source_type, str(exc))
except (urllib.error.URLError, OSError) as exc:
return _err(source_type, f"fallo de descarga: {exc}")
# Si bajamos un zip (tipico de Civitai), extraer el primer workflow de dentro.
if local_path.lower().endswith(".zip"):
try:
inner, fmt_hint = _extract_from_zip(local_path)
except _DownloadError as exc:
return _err(source_type, str(exc), path=local_path, fmt="zip")
norm = _normalize(inner, server, timeout)
norm["format_in"] = "zip"
norm["source_type"] = source_type
norm["path"] = local_path
return norm
norm = _normalize(local_path, server, timeout)
norm["source_type"] = source_type
norm["path"] = local_path
return norm
# --------------------------------------------------------------------------- #
# Deteccion + resolucion de URLs
# --------------------------------------------------------------------------- #
def _detect_source_type(source: str) -> str:
if not source.startswith(("http://", "https://")):
return "local"
host = urllib.parse.urlparse(source).netloc.lower()
if "drive.google.com" in host or "docs.google.com" in host:
return "drive"
if "civitai.com" in host:
return "civitai"
if "github.com" in host or "githubusercontent.com" in host:
return "github"
if "huggingface.co" in host:
return "huggingface"
return "direct"
def _to_raw_url(github_url: str) -> str:
"""Convierte una URL github.com/.../blob/<branch>/<path> a raw.githubusercontent.com."""
if "raw.githubusercontent.com" in github_url or "/raw/" in github_url:
return github_url
m = re.match(
r"https://github\.com/([^/]+)/([^/]+)/blob/(.+)$", github_url
)
if m:
user, repo, rest = m.groups()
return f"https://raw.githubusercontent.com/{user}/{repo}/{rest}"
return github_url # ya es raw o un patron no-blob: usar tal cual
def _drive_id(url: str) -> str | None:
m = re.search(r"/d/([A-Za-z0-9_-]+)", url) or re.search(r"[?&]id=([A-Za-z0-9_-]+)", url)
return m.group(1) if m else None
# --------------------------------------------------------------------------- #
# Descargas por fuente
# --------------------------------------------------------------------------- #
def _http_bytes(url: str, token: str | None, timeout: float) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": _UA})
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _ext_from(url_or_name: str, content: bytes) -> str:
low = url_or_name.lower().split("?")[0]
for ext in (".json", ".png", ".webp", ".zip"):
if low.endswith(ext):
return ext
if content[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if content[:4] == b"PK\x03\x04":
return ".zip"
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
return ".webp"
return ".json"
def _save(content: bytes, dest: str | None, ext: str) -> str:
if dest:
os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True)
path = dest
else:
fd, path = tempfile.mkstemp(prefix="comfy_wf_", suffix=ext)
os.close(fd)
with open(path, "wb") as f:
f.write(content)
return path
def _download_url(url: str, dest: str | None, token: str | None, timeout: float) -> str:
content = _http_bytes(url, token, timeout)
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
f"la respuesta de {url!r} es HTML, no un workflow (gated/login o URL de pagina, no raw)"
)
return _save(content, dest, _ext_from(url, content))
def _download_drive(source: str, dest: str | None, timeout: float) -> str:
file_id = _drive_id(source)
if not file_id:
raise _DownloadError(f"no se pudo extraer el file id de Drive de {source!r}")
# Camino 1: gdown (maneja el warning de virus-scan de archivos grandes).
try:
import gdown # type: ignore
out = dest or tempfile.mkstemp(prefix="comfy_wf_", suffix=".bin")[1]
got = gdown.download(id=file_id, output=out, quiet=True)
if got and os.path.exists(out) and os.path.getsize(out) > 0:
return _retype_by_content(out)
raise _DownloadError("gdown no devolvio archivo")
except ImportError:
pass # sin gdown: fallback urllib
# Camino 2: descarga directa (sirve para archivos pequenos como un .json de workflow).
url = f"https://drive.google.com/uc?export=download&id={file_id}"
content = _http_bytes(url, None, timeout)
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
"Drive devolvio HTML (archivo grande con aviso de virus-scan o gated). "
"Instala gdown (pip install gdown) para este archivo."
)
return _save(content, dest, _ext_from(source, content))
def _retype_by_content(path: str) -> str:
"""Renombra un archivo .bin descargado a su extension real segun cabecera."""
with open(path, "rb") as f:
head = f.read(16)
ext = _ext_from(path, head)
if path.lower().endswith(ext):
return path
new = os.path.splitext(path)[0] + ext
os.replace(path, new)
return new
def _download_civitai(source: str, dest: str | None, token: str | None, timeout: float) -> str:
download_url = source
# Pagina de modelo civitai.com/models/<id> -> resolver el primer file via API v1.
m = re.search(r"civitai\.com/models/(\d+)", source)
if m and "/api/download/" not in source:
api = f"https://civitai.com/api/v1/models/{m.group(1)}"
meta = json.loads(_http_bytes(api, token, timeout))
versions = meta.get("modelVersions") or []
files = (versions[0].get("files") if versions else None) or []
if not files:
raise _DownloadError(f"el modelo Civitai {m.group(1)} no expone archivos descargables")
download_url = files[0].get("downloadUrl") or ""
if not download_url:
raise _DownloadError("Civitai no devolvio downloadUrl para el modelo")
content = _http_bytes(download_url, token, timeout)
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
raise _DownloadError(
"Civitai devolvio HTML (requiere login/token o el workflow es early-access). "
"Pasa civitai_token."
)
return _save(content, dest, _ext_from(download_url, content))
def _extract_from_zip(zip_path: str) -> tuple[str, str]:
"""Extrae el primer .json/.png de un zip a un tmp y devuelve (ruta, hint)."""
with zipfile.ZipFile(zip_path) as zf:
names = [n for n in zf.namelist() if n.lower().endswith((".json", ".png", ".webp"))]
if not names:
raise _DownloadError(f"el zip {zip_path!r} no contiene .json ni .png de workflow")
name = names[0]
data = zf.read(name)
ext = os.path.splitext(name)[1].lower()
fd, out = tempfile.mkstemp(prefix="comfy_wf_zip_", suffix=ext)
os.close(fd)
with open(out, "wb") as f:
f.write(data)
return out, ext
# --------------------------------------------------------------------------- #
# Normalizacion a API format (reusa las funciones de import del registry)
# --------------------------------------------------------------------------- #
def _normalize(path: str, server: str, timeout: float) -> dict:
low = path.lower()
if low.endswith((".png", ".webp")):
res = comfyui_import_workflow_png(path, timeout=timeout)
if not res.get("ok"):
return {"ok": False, "workflow": {}, "format_in": "",
"error": res.get("error", "PNG sin workflow embebido")}
# Preferir el chunk 'prompt' (API format). Si solo hay UI graph, normalizarlo.
if res.get("prompt"):
return {"ok": True, "workflow": res["prompt"], "format_in": "png-prompt", "error": ""}
ui = res.get("workflow") or {}
if ui:
tmp = _dump_tmp_json(ui)
j = comfyui_import_workflow_json(tmp, server=server, timeout=timeout)
return {"ok": j.get("ok", False), "workflow": j.get("workflow", {}),
"format_in": "png-workflow", "error": j.get("error", "")}
return {"ok": False, "workflow": {}, "format_in": "",
"error": "PNG sin chunk prompt ni workflow"}
# .json / sin extension -> import_workflow_json (passthrough API o normaliza UI)
res = comfyui_import_workflow_json(path, server=server, timeout=timeout)
fmt = res.get("format_detected", "")
return {"ok": res.get("ok", False), "workflow": res.get("workflow", {}),
"format_in": fmt, "error": res.get("error", "")}
def _dump_tmp_json(obj: dict) -> str:
fd, tmp = tempfile.mkstemp(prefix="comfy_wf_ui_", suffix=".json")
with os.fdopen(fd, "w") as f:
json.dump(obj, f)
return tmp
def _err(source_type: str, msg: str, *, path: str = "", fmt: str = "") -> dict:
return {"ok": False, "workflow": {}, "source_type": source_type,
"path": path, "format_in": fmt, "error": msg}
class _DownloadError(Exception):
"""Error de descarga interno, traducido a {ok: False, error} en la salida."""
if __name__ == "__main__":
# Smoke: baja un workflow real de cubiq (Apache-2.0) desde GitHub raw.
url = (
"https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/"
"main/ComfyUI_Simple/SDXL_simple.json"
)
out = comfyui_download_workflow(url)
print(json.dumps({k: v for k, v in out.items() if k != "workflow"}, indent=2))
print("nodos:", len(out.get("workflow", {})))
@@ -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,104 @@
---
name: comfyui_generate_views_from_image
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_generate_views_from_image(image_name: str, *, method: str = \"auto\", server: str = \"127.0.0.1:8188\", azimuths: tuple = (90, 180, 270), elevation: float = 0.0, dest_dir: str | None = None, validate_only: bool = False, wait_timeout: float = 300.0, timeout: float = 30.0) -> dict"
description: "Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista de ComfyUI. Usa los sintetizadores NATIVOS StableZero123 (StableZero123_Conditioning_Batched, control de azimuth) o SV3D (orbita de 21 frames); en 8 GB cabe zero123 para sintesis de vistas. HONESTA: consulta /object_info, comprueba que el nodo Y su checkpoint estan instalados, y SOLO encola si hay camino viable; si no, devuelve {ok: False, reason} con la accion para habilitarlo SIN tocar la GPU. Compone object_info + submit + wait + fetch_output_image. Impura: HTTP + disco."
tags: [comfyui, ml, img-to-3d, novel-view, multiview, stablezero123, sv3d]
uses_functions: [comfyui_object_info_py_ml, comfyui_validate_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: image_name
desc: "Nombre del archivo de imagen (vista frontal) en el input/ del servidor ComfyUI. Debe existir ya (subelo con POST /upload/image)."
- name: method
desc: "'zero123' (StableZero123, control directo de azimuth), 'sv3d' (orbita SVD, mejor consistencia) o 'auto' (elige el primero cuyo nodo+checkpoint esten instalados, prefiriendo zero123). keyword-only."
- name: server
desc: "host:port de ComfyUI. keyword-only."
- name: azimuths
desc: "Angulos (grados) de las vistas a generar; 90=right, 180=back, 270=left (0=front la aporta el caller). Se asumen equiespaciados para el batch. keyword-only."
- name: elevation
desc: "Elevacion de camara en grados para todas las vistas. keyword-only."
- name: dest_dir
desc: "Carpeta local donde descargar las vistas generadas. Si None, no se descargan (solo se devuelven los nombres del output del servidor). keyword-only."
- name: validate_only
desc: "Si True, construye el workflow y lo valida contra /object_info SIN encolar ni tocar la GPU; devuelve el veredicto estructural (valid, missing_nodes, missing_models). Util para comprobar viabilidad antes de comprometer GPU. keyword-only."
- name: wait_timeout
desc: "Timeout de espera de la generacion en segundos. keyword-only."
- name: timeout
desc: "Timeout HTTP por request en segundos. keyword-only."
output: "dict. Viable: {ok: True, method, views: {back, left, right -> ruta/nombre}, prompt_id, available, reason: '', error: ''}. validate_only=True (no encola): {ok: <valido>, method, validated: True, valid, missing_nodes, missing_models, available, ...}. Sin nodo+modelo viable (stub honesto, NO encola): {ok: False, method, views: {}, reason: '<que falta y como instalarlo>', available: {nodes, ckpts, ckpt_combo}, error: ''}. Fallos de red/encolado: ok=False con error."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_generate_views_from_image.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_generate_views_from_image import comfyui_generate_views_from_image
# Comprobar viabilidad SIN encolar (no toca GPU) — recomendado antes de generar
chk = comfyui_generate_views_from_image("front.png", validate_only=True)
# chk == {"ok": True, "method": "zero123", "validated": True, "valid": True,
# "missing_nodes": [], "missing_models": [], ...} (si el ckpt esta instalado)
# 'front.png' debe estar ya en el input/ del servidor (POST /upload/image)
res = comfyui_generate_views_from_image("front.png", method="auto", dest_dir="/tmp/views")
if res["ok"]:
# res["views"] == {"right": "/tmp/views/novel_view_00001_.png",
# "back": "/tmp/views/novel_view_00002_.png",
# "left": "/tmp/views/novel_view_00003_.png"}
# -> alimentan comfyui_build_image_to_3d_multiview_workflow junto a la frontal
...
else:
# Stub honesto: el checkpoint del sintetizador no esta instalado.
print(res["reason"]) # que falta + comando para instalarlo
print(res["available"]) # {nodes: {...}, ckpts: {...}}
```
Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only). El bloque `__main__` ejecuta el caso sin modelos instalados y muestra el `ok=False` honesto.
## Cuando usarla
Cuando tengas UNA sola imagen de un objeto y quieras reconstruir un 3D multi-vista
mejor (cara trasera y laterales definidos, no alucinados). Genera con esta función las
vistas back/left/right que faltan y pásalas, junto a la frontal, a
`comfyui_build_image_to_3d_multiview_workflow` (Hunyuan3D-2mv). Si tienes fotos reales
del objeto desde varios ángulos, NO la necesitas: úsalas directamente (mejor resultado;
report 0073). Esta función es el camino sintético cuando solo hay 1 vista.
## Gotchas
- **No finge resultados**: si el nodo o su checkpoint no están instalados, devuelve
`{ok: False, reason: ...}` con el comando para habilitarlo y **NO encola nada** (no
compite por la GPU). Estado en este equipo (24/06/2026, verificado contra `/object_info`):
`stable_zero123.ckpt` **SÍ está instalado**`method='zero123'` es viable y genera de
verdad (el report 0073 lo daba por ausente; quedó desfasado). `sv3d_u.safetensors` NO
está instalado y, además, su builder aún no existe (ver gotcha siguiente).
- **`image_name` debe existir en `input/` ANTES de generar**: la función no sube la imagen
(no la inventa). Si pasas un `image_name` que no está en el `input/` del servidor, el
POST /prompt devuelve HTTP 400 (`prompt_outputs_failed_validation` de LoadImage) y la
función lo propaga como `ok=False` con el body — comportamiento correcto, no un bug.
Usa `validate_only=True` para comprobar el grafo sin necesidad de la imagen ni de GPU.
- **`method='sv3d'` aún no tiene builder**: el camino SV3D (órbita de 21 frames) lanza
`NotImplementedError` capturado → `ok=False` con error claro. Implementado: `zero123`
(StableZero123_Conditioning_Batched). Se añadirá SV3D cuando el modelo esté disponible
para probarlo (no especular: KISS).
- **Encola trabajo de GPU** sólo en el camino viable: `comfyui_submit_workflow` dispara
generación real. Respeta el aislamiento del server (coordina si otro agente lo usa).
- **Vistas sintéticas ≠ fotos reales**: no son perfectamente ortogonales ni 100%
consistentes; introducen ruido que el modelo mv puede amplificar. Para máxima fidelidad,
fotos reales > síntesis. MV-Adapter (mejor sintetizador) es custom node, fuera de alcance.
- `azimuths` se asume equiespaciado (el batch usa un incremento fijo). Mapeo de ángulo a
nombre: 0=front, 90=right, 180=back, 270=left.
@@ -0,0 +1,269 @@
"""Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista.
El camino imagen->3D de una sola vista deja indeterminada la cara trasera del objeto.
El nodo `Hunyuan3Dv2ConditioningMultiView` reconstruye mucho mejor con varias vistas
ortogonales, pero hace falta producirlas. ComfyUI 0.26.0 trae DOS sintetizadores de
vistas NATIVOS (sin custom node), confirmados en /object_info:
- StableZero123 (`StableZero123_Conditioning_Batched`): control directo de azimuth
por vista; un batch saca varias vistas en una pasada. Requiere el checkpoint
`stable_zero123.ckpt` (~8.58 GB; cabe en 8 GB solo para SINTESIS de vistas).
- SV3D (`SV3D_Conditioning`): orbita de 21 frames en una pasada, mejor consistencia;
requiere `sv3d_u.safetensors`/`sv3d_p.safetensors` (~2.3 GB; modelo de video, mas
exigente en VRAM).
Esta funcion es HONESTA sobre la viabilidad: consulta el servidor, comprueba que el
nodo Y su checkpoint estan disponibles, y SOLO encola si hay un camino viable. Si no
hay ningun (nodo + modelo) instalado, devuelve {ok: False, reason: ...} con la accion
concreta para habilitarlo, SIN tocar la GPU (no encola nada). Asi no finge un resultado
ni compite por la GPU cuando el modelo no esta.
Descartados por aislamiento: MV-Adapter y Zero123++ son custom nodes (no nativos); la
regla prohibe instalarlos aqui.
Compone comfyui_object_info + comfyui_submit_workflow + comfyui_wait_result +
comfyui_fetch_output_image. Impura: HTTP GET/POST + escritura en disco. 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
from comfyui_validate_workflow import comfyui_validate_workflow # noqa: E402
from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402
from comfyui_wait_result import comfyui_wait_result # noqa: E402
from comfyui_fetch_output_image import comfyui_fetch_output_image # noqa: E402
# Checkpoint requerido por cada metodo (lo carga ImageOnlyCheckpointLoader).
_METHOD_CKPT = {
"zero123": "stable_zero123.ckpt",
"sv3d": "sv3d_u.safetensors",
}
# azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller).
_AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"}
def comfyui_generate_views_from_image(
image_name: str,
*,
method: str = "auto",
server: str = "127.0.0.1:8188",
azimuths: tuple = (90, 180, 270),
elevation: float = 0.0,
dest_dir: str | None = None,
validate_only: bool = False,
wait_timeout: float = 300.0,
timeout: float = 30.0,
) -> dict:
"""Sintetiza vistas novel-view desde una imagen ya subida al input/ de ComfyUI.
Args:
image_name: nombre del archivo de imagen en el `input/` del servidor
(la vista frontal). Debe existir ya (subelo con POST /upload/image).
method: 'zero123' (StableZero123, control de azimuth), 'sv3d' (orbita
SVD) o 'auto' (elige el primero cuyo nodo+checkpoint esten
instalados, prefiriendo zero123). keyword-only.
server: host:port de ComfyUI. keyword-only.
azimuths: angulos (grados) de las vistas a generar; 90=right, 180=back,
270=left (0=front la aporta el caller). Se asumen equiespaciados para
el batch. keyword-only.
elevation: elevacion de camara en grados para todas las vistas.
keyword-only.
dest_dir: carpeta local donde descargar las vistas generadas. Si None, no
se descargan (solo se devuelven los nombres del output del servidor).
keyword-only.
validate_only: si True, construye el workflow y lo VALIDA contra
/object_info (comfyui_validate_workflow) SIN encolar ni tocar la GPU,
devolviendo el veredicto estructural. Util para comprobar viabilidad
antes de comprometer GPU (y para smoke sin generar). keyword-only.
wait_timeout: timeout de espera de la generacion en segundos. keyword-only.
timeout: timeout HTTP por request en segundos. keyword-only.
Returns:
dict. Si hay camino viable y se genera:
{ok: True, method, views: {"back": <ruta/nombre>, "left": ..., "right": ...},
prompt_id, available: {...}, reason: "", error: ""}.
Con validate_only=True (no encola):
{ok: <valido>, method, validated: True, valid, missing_nodes,
missing_models, views: {}, available: {...}, reason: "", error: ""}.
Si NINGUN nodo+modelo viable (stub honesto, no encola):
{ok: False, method, views: {}, reason: "<que falta y como instalarlo>",
available: {nodes: {...}, ckpts: {...}}, error: ""}.
Cualquier fallo de red/encolado tambien devuelve ok=False con error.
"""
# 1. Inventario del servidor (impuro, solo lectura: NO encola, NO toca GPU).
try:
oi = comfyui_object_info(server=server, timeout=timeout)
except Exception as exc: # noqa: BLE001
return _stub(method, f"no se pudo consultar /object_info de {server}: {exc}", error=str(exc))
nodes_present = {
"zero123": "StableZero123_Conditioning_Batched" in oi,
"sv3d": "SV3D_Conditioning" in oi,
}
ckpts = _checkpoint_combo(oi)
ckpts_present = {m: (_METHOD_CKPT[m] in ckpts) for m in _METHOD_CKPT}
available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts}
# 2. Elegir metodo viable (nodo + checkpoint presentes).
order = [method] if method in _METHOD_CKPT else ["zero123", "sv3d"]
chosen = next(
(m for m in order if nodes_present.get(m) and ckpts_present.get(m)),
None,
)
if chosen is None:
return _stub(
method,
_why_unavailable(order, nodes_present, ckpts_present),
available=available,
)
# 3. Construir el workflow.
try:
wf = _build_views_workflow(image_name, chosen, ckpts[_method_ckpt_key(chosen, ckpts)],
azimuths, elevation)
except NotImplementedError as exc:
return _stub(chosen, str(exc), available=available, error=str(exc))
# 3a. Modo validate_only: valida contra /object_info SIN encolar (no toca GPU).
if validate_only:
val = comfyui_validate_workflow(wf, server=server, timeout=timeout)
return {"ok": bool(val.get("valid")), "method": chosen, "validated": True,
"valid": val.get("valid"), "missing_nodes": val.get("missing_nodes", []),
"missing_models": val.get("missing_models", []), "views": {},
"available": available, "reason": "", "error": val.get("error", "")}
# 3b. Encolar y generar (solo si hay camino viable y NO es validate_only).
try:
sub = comfyui_submit_workflow(wf, server=server, timeout=timeout)
prompt_id = sub.get("prompt_id")
if not prompt_id:
return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available)
comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout)
return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id,
"available": available, "reason": "", "error": ""}
except Exception as exc: # noqa: BLE001
return _stub(chosen, f"fallo al generar vistas: {exc}", available=available, error=str(exc))
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _checkpoint_combo(oi: dict) -> list:
"""Lista de checkpoints que el servidor ofrece a ImageOnlyCheckpointLoader."""
for node in ("ImageOnlyCheckpointLoader", "CheckpointLoaderSimple"):
spec = (oi.get(node) or {}).get("input", {}).get("required", {})
decl = spec.get("ckpt_name")
if isinstance(decl, list) and decl and isinstance(decl[0], list):
return list(decl[0])
return []
def _method_ckpt_key(method: str, ckpts: list) -> int:
return ckpts.index(_METHOD_CKPT[method])
def _why_unavailable(order, nodes_present, ckpts_present) -> str:
parts = []
for m in order:
if m not in _METHOD_CKPT:
continue
if not nodes_present.get(m):
parts.append(f"{m}: nodo nativo ausente en el servidor")
elif not ckpts_present.get(m):
ck = _METHOD_CKPT[m]
parts.append(
f"{m}: nodo OK pero falta el checkpoint '{ck}'. "
f"Instalalo con comfyui_download_model(<url_{m}>, dest_subdir='checkpoints')"
)
return ("Ningun sintetizador de vistas nativo viable en 8 GB esta listo. "
+ " | ".join(parts) + ". Alternativa: aporta fotos reales del objeto "
"(front/left/back/right) y usa comfyui_build_image_to_3d_multiview_workflow directamente.")
def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation) -> dict:
"""Workflow batched de sintesis de vistas. Hoy implementado para StableZero123."""
if method == "sv3d":
raise NotImplementedError(
"el builder SV3D (orbita de 21 frames) no esta implementado todavia; usa method='zero123'"
)
azs = sorted(azimuths)
start = azs[0]
increment = (azs[1] - azs[0]) if len(azs) > 1 else 90
batch = len(azs)
return {
"1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}},
"3": {
"class_type": "StableZero123_Conditioning_Batched",
"inputs": {
"clip_vision": ["2", 1],
"init_image": ["1", 0],
"vae": ["2", 2],
"width": 256,
"height": 256,
"batch_size": batch,
"elevation": elevation,
"azimuth": start,
"elevation_batch_increment": 0.0,
"azimuth_batch_increment": increment,
},
},
"4": {
"class_type": "KSampler",
"inputs": {
"seed": 0, "steps": 20, "cfg": 4.0, "sampler_name": "euler",
"scheduler": "normal", "denoise": 1.0,
"model": ["2", 0], "positive": ["3", 0], "negative": ["3", 1],
"latent_image": ["3", 2],
},
},
"5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0], "vae": ["2", 2]}},
"6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "filename_prefix": "novel_view"}},
}
def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict:
"""Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista."""
import json
import urllib.request
url = f"http://{server}/history/{prompt_id}"
with urllib.request.urlopen(url, timeout=timeout) as resp:
hist = json.load(resp)
outputs = (hist.get(prompt_id) or {}).get("outputs", {})
images = []
for node_out in outputs.values():
images.extend(node_out.get("images", []))
azs = sorted(azimuths)
views = {}
for img, az in zip(images, azs):
name = _AZIMUTH_NAME.get(az, f"az{az}")
if dest_dir:
got = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=dest_dir, timeout=60.0,
)
views[name] = got.get("path", img["filename"])
else:
views[name] = img["filename"]
return views
def _stub(method, reason, *, available=None, error="") -> dict:
return {"ok": False, "method": method, "views": {}, "reason": reason,
"available": available or {}, "error": error}
if __name__ == "__main__":
import json
# Smoke ligero: valida el camino sin encolar (no toca GPU). Si el checkpoint
# del sintetizador no esta instalado, devuelve el stub honesto ok=False.
res = comfyui_generate_views_from_image("front.png", validate_only=True)
print(json.dumps({k: v for k, v in res.items() if k != "available"}, indent=2))
print("available:", json.dumps(res.get("available", {}).get("ckpts", {})))
@@ -0,0 +1,85 @@
---
name: comfyui_import_workflow_json
kind: function
lang: py
domain: ml
version: "1.1.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. Omite los nodos virtuales del editor (Note, MarkdownNote, PrimitiveNode, Reroute) tal como hace ComfyUI al pasar UI->API: resuelve los Reroute reconectando la conexion directa origen->destino e inyecta los PrimitiveNode como valor de widget en el consumidor. 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".
- Los nodos virtuales del editor (`Note`, `MarkdownNote`, `PrimitiveNode`,
`Reroute` y variantes `Reroute*`) NO aparecen en el API format resultante —
igual que cuando ComfyUI exporta UI->API. Los `Reroute` se resuelven saltando
el passthrough y reconectando el origen real al consumidor; una cadena de
Reroutes rota (entrada sin link) o con ciclo deja el input sin conexion en
lugar de apuntar a un nodo inexistente. Los `PrimitiveNode` se inyectan como
valor literal de widget en el consumidor (su `widgets_values[0]`).
- El filtrado es idempotente: un workflow ya en API format (sin nodos virtuales)
pasa intacto; un UI graph sin virtuales conserva todas sus conexiones.
## Capability growth log
- v1.1.0 (2026-06-24) — la conversion UI->API omite los nodos virtuales del
editor (Note/MarkdownNote/PrimitiveNode/Reroute), resuelve los Reroute
reconectando origen->destino e inyecta los PrimitiveNode como valor de widget.
Antes esos nodos viajaban al API format y `comfyui_validate_workflow` los
marcaba como `missing_nodes` (falsos positivos). Gap del report 0086.
@@ -0,0 +1,202 @@
"""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)"}
# Node types virtuales del editor de ComfyUI: solo existen en el UI graph y se
# descartan al pasar UI -> API (ComfyUI hace lo mismo). Note/MarkdownNote son
# anotaciones; PrimitiveNode inyecta un valor de widget; Reroute es un passthrough
# de una conexion (se resuelve reconectando origen real -> destino).
_NOTE_TYPES = {"Note", "MarkdownNote"}
def _is_reroute(ctype) -> bool:
"""True si el node type es un Reroute (nativo 'Reroute' o variantes custom)."""
return isinstance(ctype, str) and ctype.startswith("Reroute")
def _is_virtual(ctype) -> bool:
"""True si el node type es virtual del editor (no va al API format)."""
return ctype in _NOTE_TYPES or ctype == "PrimitiveNode" or _is_reroute(ctype)
def _resolve_source(src_node, src_slot, node_by_id, link_src, _depth=0):
"""Resuelve el origen real de una conexion saltando los nodos Reroute.
Un Reroute en el UI graph es un passthrough: su salida solo reenvia lo que
llega a su unica entrada. Para producir API format hay que reconectar el
consumidor directamente al origen real (origen -> destino, sin el Reroute).
Devuelve (node_id, slot) del nodo no-Reroute al que se conecta, o None si la
cadena de Reroutes esta rota (entrada sin link) o forma un ciclo.
"""
if _depth > 64:
return None # ciclo de Reroutes: aborta la resolucion.
node = node_by_id.get(src_node)
if node is None or not _is_reroute(node.get("type")):
return (src_node, src_slot)
link = None
for inp in node.get("inputs", []) or []:
if inp.get("link") is not None:
link = inp["link"]
break
if link is None or link not in link_src:
return None # Reroute sin entrada conectada: link muerto.
nxt_node, nxt_slot = link_src[link]
return _resolve_source(nxt_node, nxt_slot, node_by_id, link_src, _depth + 1)
def _ui_graph_to_api(graph: dict, obj_info) -> dict:
"""Convierte un UI graph de ComfyUI a API format (best-effort).
Omite los nodos virtuales del editor (Note, MarkdownNote, PrimitiveNode,
Reroute) tal como hace ComfyUI al pasar de UI a API: las anotaciones se
descartan, los Reroute se resuelven reconectando la conexion directa
origen->destino, y los PrimitiveNode se inyectan como valor de widget en el
consumidor.
"""
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])
# node_id (str) -> node dict, para TODOS los nodos (incluidos los virtuales),
# necesario para resolver Reroutes e inyectar valores de PrimitiveNode.
node_by_id = {str(n.get("id")): n for n in nodes if n.get("id") is not None}
api = {}
for node in nodes:
ctype = node.get("type")
if ctype is None or _is_virtual(ctype):
continue # los virtuales no existen en API format.
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 None or link is None or link not in link_src:
continue
src_node, src_slot = link_src[link]
resolved = _resolve_source(src_node, src_slot, node_by_id, link_src)
if resolved is None:
continue # cadena de Reroutes rota: el input queda sin conexion.
rnode, rslot = resolved
src = node_by_id.get(rnode)
if src is not None and src.get("type") == "PrimitiveNode":
# PrimitiveNode: inyecta su valor constante como widget, no como link.
wv = src.get("widgets_values")
if isinstance(wv, list) and wv:
inputs[name] = wv[0]
connected.add(name)
continue
inputs[name] = [rnode, rslot]
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: true
tests: ["no muta el dict de entrada (pureza)", "inserta LoraLoader con strength correcto", "reconecta KSampler.model al LoRA", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_inject_lora.py"
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,60 @@
---
name: comfyui_interrupt_queue
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
tags: [comfyui, ml, queue, interrupt, control, 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')."
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
res = comfyui_interrupt_queue()
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
if res["ok"] and res["interrupted"]:
print(f"cortado; pendientes en cola: {res['queue_pending']}")
```
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
## Cuando usarla
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
## Gotchas
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
+ lee).
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
`ok` antes de fiarte de los conteos.
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
trabajo pesado (esta funcion no lo hace).
@@ -0,0 +1,71 @@
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
"""
import json
import urllib.error
import urllib.request
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
Args:
server: host:port del servidor ComfyUI sin esquema (default
"127.0.0.1:8188").
Returns:
dict con:
- ok (bool): True si tanto el interrupt como la lectura de la cola
tuvieron exito.
- interrupted (bool): True si el POST /interrupt respondio sin error.
- queue_running (int): numero de prompts ejecutandose ahora mismo.
- queue_pending (int): numero de prompts encolados pendientes.
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
"""
out = {
"ok": False,
"interrupted": False,
"queue_running": 0,
"queue_pending": 0,
"error": "",
}
base = f"http://{server}"
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
try:
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
with urllib.request.urlopen(req, timeout=10.0):
out["interrupted"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
return out
# 2. GET /queue: estado actual de la cola tras el interrupt.
try:
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
data = json.loads(resp.read())
out["queue_running"] = len(data.get("queue_running", []))
out["queue_pending"] = len(data.get("queue_pending", []))
out["ok"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"queue fallo: no se pudo conectar a {base}/queue: {reason}"
except json.JSONDecodeError as exc:
out["error"] = f"queue fallo: respuesta no es JSON valido: {exc}"
return out
if __name__ == "__main__":
res = comfyui_interrupt_queue()
print(
f"ok={res['ok']} interrupted={res['interrupted']} "
f"running={res['queue_running']} pending={res['queue_pending']} "
f"error={res['error']!r}"
)
@@ -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,82 @@
---
name: comfyui_make_watertight
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_make_watertight(in_path: str, *, method: str = \"voxel\", pitch: float | None = None, out_path: str | None = None) -> dict"
description: "Hace estanca (watertight) una malla 3D GLB/OBJ/PLY de ComfyUI/Hunyuan3D. method='voxel' (default) voxeliza el solido, rellena el interior y reconstruye con marching cubes (trimesh) -> is_watertight=True garantizado, a costa de mas caras y de descartar la apariencia; necesita scikit-image. method='repair' hace limpieza ligera (trimesh.repair fill_holes + fix_normals + fix_winding) conservando el detalle, pero no garantiza estanqueidad en mallas muy rotas. La via de raiz es generar con el nodo VoxelToMesh algorithm='surface net' (report 0088). Impura: lee y escribe en disco."
tags: [comfyui, 3d, mesh, hunyuan3d, watertight, voxel, remesh, trimesh, ml]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: in_path
desc: "Ruta de la malla de entrada (.glb/.obj/.ply/.gltf/.stl/.off). Scene GLB con varias geometrias se concatena en una sola malla."
- name: method
desc: "'voxel' (default, garantiza is_watertight=True via voxeliza+fill+marching cubes) o 'repair' (fill_holes + fix_normals, conserva detalle pero no siempre estanca). keyword-only."
- name: pitch
desc: "Solo para method='voxel'. Tamano de voxel absoluto. Si None, se calcula como diagonal_bbox / 200. Mas pequeno = mas caras y mas detalle, mas lento. keyword-only."
- name: out_path
desc: "Ruta de salida. Si None, escribe '<in>_watertight.glb' junto al original (NO sobrescribe). keyword-only."
output: "dict {ok, was_watertight, is_watertight, out_path, method, pitch, out_faces, error}. was/is_watertight = estanqueidad antes/despues medida con trimesh.is_watertight. out_faces = caras del resultado (el voxel-remesh suele aumentarlas). Si falla, ok=False y error explica (dependencia ausente, method invalido, archivo no existe, carga o remesh)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_make_watertight.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_make_watertight import comfyui_make_watertight
# Cierra una malla decimada no estanca (80k caras) por voxel-remesh.
res = comfyui_make_watertight(
"/tmp/character_simplified.glb",
method="voxel",
out_path="/tmp/character_watertight.glb",
)
# res == {"ok": True, "was_watertight": False, "is_watertight": True,
# "method": "voxel", "pitch": 0.01596, "out_faces": 250672,
# "out_path": "/tmp/character_watertight.glb", "error": ""}
```
Lanzar con el python del venv del registry (import de arriba o heredoc). `./fn run`
directo no aplica: la firma usa `*` (keyword-only), no soportado por el runner de `fn run`.
## Cuando usarla
Cuando una malla de ComfyUI/Hunyuan3D sale NO estanca (`is_watertight=False`, tipico
del nodo DEPRECATED `VoxelToMeshBasic`) y la necesitas cerrada para imprimir en 3D,
calcular volumen, boolean ops o simulacion. Usa `method="voxel"` cuando exiges
estanqueidad garantizada (la silueta se conserva, el detalle fino se suaviza).
Usa `method="repair"` cuando la malla solo tiene huecos pequenos y quieres conservar
caras/detalle. Para malla ligera Y estanca, decima antes con `comfyui_simplify_mesh`
y luego pasa el resultado por aqui con `method="voxel"`.
## Gotchas
- Impura: lee `in_path` y escribe `out_path`. Nunca sobrescribe el original salvo
que apuntes `out_path` a el.
- `method="voxel"` DESCARTA la apariencia (UV / vertex colors): el marching cubes
genera geometria nueva sin atributos. Si quieres color, aplicalo despues del
remesh, o usa la via de raiz (`VoxelToMesh surface net`) que preserva el flujo de
texturizado.
- `method="voxel"` necesita `scikit-image` en el venv (marching cubes). Sin el
devuelve ok=False con la instruccion `uv add scikit-image`.
- `method="repair"` NO garantiza `is_watertight=True`: en mallas muy rotas
(cube-soup con muchos huecos grandes) devuelve `is_watertight=False`. Es
esperado; para garantia usa `method="voxel"`.
- El voxel-remesh aumenta el numero de caras (densidad del marching cubes). Si
necesitas ligero Y estanco, vuelve a decimar el resultado con
`comfyui_simplify_mesh` (el report 0088 logra 80k caras + estanco re-cerrando).
- `euler_number` puede quedar negativo aunque sea estanco: indica genus alto real
(tuneles de la geometria), no un fallo. Estanco != genus 0.
- `pitch` muy pequeno sobre mallas grandes es lento (corre en CPU, no usa VRAM).
@@ -0,0 +1,133 @@
"""Hace estanca (watertight) una malla 3D GLB/OBJ/PLY.
Post-proceso de las mallas de ComfyUI/Hunyuan3D producidas con el nodo
VoxelToMeshBasic (DEPRECATED), que genera mallas NO estancas (is_watertight=False):
crea 4 vertices nuevos por cara expuesta sin soldarlos, dejando huecos y bordes
non-manifold. Dos metodos:
- method="voxel" (default, garantiza is_watertight=True): voxeliza el solido,
rellena el interior y reconstruye la superficie con marching cubes
(trimesh voxelized(pitch).fill().marching_cubes). Produce una malla cerrada por
construccion. Coste: mas caras (densidad del marching cubes) y descarta la
apariencia (UV/vertex colors). Necesita scikit-image (marching cubes).
- method="repair": limpieza ligera con trimesh.repair (fix_winding + fill_holes +
fix_normals + merge_vertices). Conserva el detalle y las caras, pero NO garantiza
estanqueidad en mallas muy rotas (solo cierra huecos pequenos).
La via de RAIZ (no este post-proceso) es generar con el nodo VoxelToMesh
algorithm='surface net', que da malla manifold cerrada sin reparar (ver report 0088).
Impura: lee y escribe archivos en disco. Requiere trimesh (+ scikit-image para voxel).
"""
import os
import numpy as np
def _load_mesh(path):
import trimesh
obj = trimesh.load(path, process=False)
if isinstance(obj, trimesh.Scene):
obj = trimesh.util.concatenate(list(obj.geometry.values()))
return obj
def comfyui_make_watertight(
in_path: str,
*,
method: str = "voxel",
pitch: float | None = None,
out_path: str | None = None,
) -> dict:
"""Hace estanca una malla GLB/OBJ/PLY por voxel-remesh o reparacion.
Args:
in_path: ruta de la malla de entrada (.glb/.obj/.ply/.gltf/.stl/.off).
method: "voxel" (default, garantiza is_watertight=True via voxeliza+fill+
marching cubes) o "repair" (fill_holes + fix_normals, conserva detalle
pero no siempre estanca). keyword-only.
pitch: solo para method="voxel". Tamano de voxel absoluto. Si None, se
calcula como diagonal_bbox / 200 (mas fino = mas caras y detalle).
keyword-only.
out_path: ruta de salida. Si None, escribe "<in>_watertight.glb" junto al
original. keyword-only.
Returns:
dict {ok, was_watertight, is_watertight, out_path, method, pitch, out_faces,
error}. was/is_watertight = estanqueidad antes/despues (trimesh). Si falla,
ok=False y error explica.
"""
base_err = {
"ok": False, "was_watertight": None, "is_watertight": None,
"out_path": "", "method": method, "pitch": None, "out_faces": 0,
}
try:
import trimesh
except ImportError as exc:
return {**base_err, "error": f"falta trimesh: {exc}. cd python && uv add trimesh"}
if method not in ("voxel", "repair"):
return {**base_err, "error": f"method '{method}' invalido (usa 'voxel' o 'repair')"}
if not os.path.exists(in_path):
return {**base_err, "error": f"no existe el archivo de entrada: {in_path!r}"}
if out_path is None:
out_path = os.path.splitext(in_path)[0] + "_watertight.glb"
try:
mesh = _load_mesh(in_path)
except Exception as exc:
return {**base_err, "error": f"no se pudo cargar la malla {in_path!r}: {exc}"}
was = bool(mesh.is_watertight)
try:
if method == "voxel":
m = mesh.copy()
m.merge_vertices()
if pitch is None:
diag = float(np.linalg.norm(m.extents))
pitch = diag / 200.0
vg = m.voxelized(pitch=float(pitch)).fill()
out = vg.marching_cubes
out.merge_vertices()
trimesh.repair.fix_normals(out)
else: # repair
out = mesh.copy()
out.merge_vertices()
trimesh.repair.fix_winding(out)
trimesh.repair.fill_holes(out)
trimesh.repair.fix_normals(out)
parent = os.path.dirname(out_path)
if parent:
os.makedirs(parent, exist_ok=True)
out.export(out_path)
except ImportError as exc:
return {**base_err, "was_watertight": was,
"error": f"falta dependencia para method='{method}': {exc}. "
f"El voxel-remesh necesita scikit-image: cd python && uv add scikit-image"}
except Exception as exc:
return {**base_err, "was_watertight": was,
"error": f"fallo en method='{method}': {type(exc).__name__}: {exc}"}
return {
"ok": True,
"was_watertight": was,
"is_watertight": bool(out.is_watertight),
"out_path": out_path,
"method": method,
"pitch": round(float(pitch), 6) if pitch is not None else None,
"out_faces": int(len(out.faces)),
"error": "",
}
if __name__ == "__main__":
import json
import sys
src = sys.argv[1] if len(sys.argv) > 1 else (
os.path.expanduser("~/ComfyUI/output/3d_robot_mesh_00001__dec80k.glb"))
method = sys.argv[2] if len(sys.argv) > 2 else "voxel"
out = sys.argv[3] if len(sys.argv) > 3 else None
print(json.dumps(comfyui_make_watertight(src, method=method, out_path=out), 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,77 @@
---
name: comfyui_queue_manage
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_queue_manage(action: str, *, server: str = \"127.0.0.1:8188\", prompt_id: str | None = None) -> dict"
description: "Gestiona la cola y el historial de ComfyUI via su API HTTP. action='status' (GET /queue -> queue_running/queue_pending), 'clear' (POST /queue {\"clear\":true} -> vacia pendientes), 'delete' (POST /queue {\"delete\":[prompt_id]} -> borra un prompt, requiere prompt_id), 'history' (GET /history -> history_count). Completa lo que comfyui_interrupt_queue no cubre. Devuelve {ok, action, queue_running, queue_pending, history_count, error}. NO lanza en fallo de red: degrada a {ok:False, error}. Impura: HTTP GET/POST, solo stdlib (urllib, json)."
tags: [comfyui, ml, queue, history, control, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: action
desc: "operacion: 'status' (estado de la cola), 'clear' (vaciar pendientes), 'delete' (borrar un prompt; requiere prompt_id), 'history' (contar historial)."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: prompt_id
desc: "id del prompt a borrar; obligatorio solo para action='delete'."
output: "dict con ok (bool), action (str, eco), queue_running (int, prompts ejecutandose; status/clear/delete), queue_pending (int, prompts encolados; status/clear/delete), history_count (int, prompts en el historial; action='history'), error (str, vacio si OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_queue_manage.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_queue_manage import comfyui_queue_manage
# Estado de la cola
st = comfyui_queue_manage("status")
# {'ok': True, 'action': 'status', 'queue_running': 1, 'queue_pending': 3, 'history_count': 0, 'error': ''}
# Cuantos prompts recuerda el historial
h = comfyui_queue_manage("history")
print(h["history_count"])
# Vaciar los pendientes (no corta el que se ejecuta; para eso, comfyui_interrupt_queue)
comfyui_queue_manage("clear")
# Borrar un prompt concreto de la cola de pendientes
comfyui_queue_manage("delete", prompt_id="abc123-...")
```
O lanzable directo: `./fn run comfyui_queue_manage status` · `./fn run comfyui_queue_manage history`.
## Cuando usarla
Cuando necesitas operar la cola mas alla de cortar el prompt en curso: ver de un
vistazo cuanto queda (`status`), limpiar de golpe un barrido de seeds que ya no
quieres (`clear`), quitar un prompt pesado encolado por error sin matar el que se
ejecuta (`delete`), o saber cuantas generaciones recuerda el servidor (`history`).
Es el complemento de `comfyui_interrupt_queue` (que solo corta + lee) para cubrir
las cuatro acciones restantes de `/queue` y `/history`.
## Gotchas
- `clear` vacia SOLO los pendientes; el prompt en ejecucion sigue. Para cortarlo
usa `comfyui_interrupt_queue` (POST /interrupt) antes del `clear`.
- `delete` requiere `prompt_id`; sin el devuelve `ok=False` con el error. El id es
el que devuelve `comfyui_submit_workflow`. Borrar un prompt que ya no esta en la
cola es inocuo (el servidor lo ignora).
- En `status`/`clear`/`delete` se rellenan `queue_running`/`queue_pending`; en
`history` se rellena `history_count` (los otros quedan en 0). Mira `action` para
saber que campos son significativos.
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`.
Comprueba `ok` antes de fiarte de los conteos.
- `history_count` es el numero de entradas que el servidor mantiene en memoria, no
un acumulado historico persistente: se reinicia al reiniciar ComfyUI.
+135
View File
@@ -0,0 +1,135 @@
"""Gestiona la cola y el historial de un servidor ComfyUI via su API HTTP.
Funcion impura: hace red (HTTP GET/POST). Solo stdlib (urllib, json).
Completa lo que comfyui_interrupt_queue no cubre. interrupt_queue corta el prompt
en ejecucion; esta funcion expone las cuatro operaciones restantes de la cola:
- "status": GET /queue -> cuantos prompts se ejecutan ahora (queue_running) y
cuantos estan encolados pendientes (queue_pending).
- "clear": POST /queue {"clear": true} -> vacia los pendientes de golpe.
- "delete": POST /queue {"delete": [prompt_id]} -> borra un prompt concreto de la
cola de pendientes (requiere prompt_id).
- "history": GET /history -> numero de prompts ya ejecutados que el servidor
recuerda (history_count).
NO lanza excepcion en fallo de red: degrada a {ok: False, error}.
"""
import json
import urllib.error
import urllib.request
def comfyui_queue_manage(
action: str,
*,
server: str = "127.0.0.1:8188",
prompt_id: str | None = None,
) -> dict:
"""Opera la cola/historial de ComfyUI: status, clear, delete o history.
Args:
action: operacion a realizar. Una de:
- "status": lee el estado de la cola.
- "clear": vacia los prompts pendientes (POST /queue {"clear": true}).
- "delete": borra un prompt concreto (POST /queue {"delete": [id]});
requiere prompt_id.
- "history": cuenta los prompts en el historial (GET /history).
server: host:port del servidor ComfyUI sin esquema (default
"127.0.0.1:8188"). keyword-only.
prompt_id: id del prompt a borrar; obligatorio solo para action="delete".
keyword-only.
Returns:
dict con:
- ok (bool): True si la operacion se completo sin error.
- action (str): la accion solicitada (eco).
- queue_running (int): prompts ejecutandose ahora (status/clear/delete).
- queue_pending (int): prompts encolados pendientes (status/clear/delete).
- history_count (int): numero de prompts en el historial (action=history).
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {
"ok": False,
"action": action,
"queue_running": 0,
"queue_pending": 0,
"history_count": 0,
"error": "",
}
base = f"http://{server}"
valid = {"status", "clear", "delete", "history"}
if action not in valid:
out["error"] = f"action desconocida: {action!r}; usa una de {sorted(valid)}"
return out
def _read_queue() -> bool:
"""Rellena queue_running/queue_pending desde GET /queue. True si OK."""
try:
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
data = json.loads(resp.read())
out["queue_running"] = len(data.get("queue_running", []))
out["queue_pending"] = len(data.get("queue_pending", []))
return True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"GET /queue fallo: no se pudo conectar a {base}/queue: {reason}"
except json.JSONDecodeError as exc:
out["error"] = f"GET /queue fallo: respuesta no es JSON valido: {exc}"
return False
def _post_queue(body: dict) -> bool:
"""POST /queue con cuerpo JSON. True si el servidor respondio sin error."""
try:
payload = json.dumps(body).encode()
req = urllib.request.Request(
f"{base}/queue",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10.0):
return True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"POST /queue fallo: no se pudo conectar a {base}/queue: {reason}"
return False
if action == "status":
out["ok"] = _read_queue()
return out
if action == "clear":
if _post_queue({"clear": True}):
out["ok"] = _read_queue()
return out
if action == "delete":
if not prompt_id:
out["error"] = "action='delete' requiere prompt_id"
return out
if _post_queue({"delete": [prompt_id]}):
out["ok"] = _read_queue()
return out
# action == "history"
try:
with urllib.request.urlopen(f"{base}/history", timeout=15.0) as resp:
hist = json.loads(resp.read())
out["history_count"] = len(hist) if isinstance(hist, dict) else 0
out["ok"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"GET /history fallo: no se pudo conectar a {base}/history: {reason}"
except json.JSONDecodeError as exc:
out["error"] = f"GET /history fallo: respuesta no es JSON valido: {exc}"
return out
if __name__ == "__main__":
import sys
act = sys.argv[1] if len(sys.argv) > 1 else "status"
pid = sys.argv[2] if len(sys.argv) > 2 else None
res = comfyui_queue_manage(act, prompt_id=pid)
print(json.dumps(res, indent=2))
@@ -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))

Some files were not shown because too many files have changed in this diff Show More