Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8121e4b04e | |||
| 4302212b34 | |||
| 394221f8c7 | |||
| 69d9aed46a | |||
| c36c80dda9 | |||
| 3887e59092 | |||
| d5660aa13f | |||
| a56b6e36ea | |||
| 5f0df32728 | |||
| 6d1b66167d | |||
| 04ecf9f394 | |||
| 46954d8584 | |||
| 6f4b440762 | |||
| bcf731275e | |||
| 974cc06bc7 | |||
| 70d541fca9 | |||
| e8a66f0dad | |||
| 898502a321 | |||
| 2fe36e314e | |||
| 11ef8ef6db | |||
| 3e75d1bf79 | |||
| 68f0ce0dae | |||
| c0b2dce3b0 | |||
| ff41f4f053 | |||
| f686b338d6 | |||
| 3823a28d1c | |||
| 337f75b527 | |||
| d3f05a19a5 | |||
| d7245efa59 | |||
| 1311c7e585 | |||
| db4f454f8a | |||
| f12272d002 | |||
| 495f545ec1 | |||
| f34badb500 | |||
| 3289c67986 | |||
| bcc1fe1738 | |||
| 7619347be8 | |||
| f55e41cf74 | |||
| e2e8669edf | |||
| 86d68dc9f0 | |||
| b18759823d | |||
| a59d50238d | |||
| f17d957a8f | |||
| c1f355ffa5 | |||
| 237f763c19 | |||
| bf67ff3180 | |||
| 03fc0461fa | |||
| a1105dc4c5 | |||
| 3c9e909eda | |||
| 3cf8b21fea | |||
| cbefc82c02 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
|
||||
|
||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate, save, bump, harvest, judge, critique`
|
||||
|
||||
### Excepciones
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"registry",
|
||||
"jupyter"
|
||||
"jupyter",
|
||||
"orchestrator"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
||||
},
|
||||
"godot": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -3,10 +3,10 @@ name: kill_fleet_agent
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
purity: impure
|
||||
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]"
|
||||
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux (kill-window) en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json) ni a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc). Por defecto EJECUTA; --dry-run imprime el plan sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
|
||||
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Tres guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json); NUNCA a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc); y NUNCA cierra la window que aloja la TUI fleetview o la window 'console' con kill-window (eso se llevaria el panel de control por delante) — en ese caso cierra SOLO el pane del target con kill-pane y preserva la TUI. Por defecto EJECUTA; --dry-run imprime el plan (incluida la accion kill-pane vs kill-window) sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
|
||||
tags: [fleet, claude-fleet, orchestration, tmux, kill, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -17,6 +17,7 @@ tests:
|
||||
- "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan"
|
||||
- "guard: matar un role=orchestrator devuelve rc=3 y se niega"
|
||||
- "guard: matar la sesion actual (self) devuelve rc=3 y se niega"
|
||||
- "guard3: predicado _fleet_window_hosts_tui detecta window 'console' o pane fleetview"
|
||||
- "error: target no resuelto rc=2; sin target rc=2"
|
||||
test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh"
|
||||
params:
|
||||
@@ -55,11 +56,13 @@ Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claud
|
||||
- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes.
|
||||
- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/<sessionId>.json` (lo escribe `mark_claude_role`).
|
||||
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`".
|
||||
- **Resolución de la window**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
|
||||
- **Guard 3 — anti-TUI/console (no decapitar el panel)**: antes de cerrar nada, comprueba si la window del target **aloja la TUI fleetview** (algún pane corre el binario `fleetview`) o se llama **`console`**. El layout FleetView mete la TUI y un Claude en la misma window `console`, y los focus-swaps (`join-pane`) pueden meter al ejecutor target en esa window; un `kill-window` ahí se llevaría la TUI por delante (causa del fallo descrito en `fleetview` v0.4.3). En ese caso la función NO usa `kill-window`: manda el SIGTERM al claude y cierra **solo su pane** con `kill-pane`, preservando el pane de la TUI. El plan (y el `--dry-run`) lo refleja como `accion: kill-pane … (aloja la TUI/console)` vs `accion: kill-window …`. El predicado es la función interna `_fleet_window_hosts_tui` (testeada). Se mantiene inline (no función propia del registry) por estar acoplada a este flujo y para no dejar una capacidad huérfana (KISS).
|
||||
- **Resolución de la window y el pane**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`, capturando `window_id`, `pane_id` y `window_name`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
|
||||
- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume <sessionId>`.
|
||||
- **Requiere `jq`** para leer los JSON de sessions/goals.
|
||||
- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(v1.0.0 — sin cambios todavía.)
|
||||
- v1.1.0 (2026-06-24) — **Guard 3 anti-TUI/console** (elimina un gotcha conocido). Antes, si un focus-swap metía al ejecutor target en la window `console` (la que aloja la TUI fleetview), `kill-window` cerraba la TUI por error. Ahora, cuando la window del target aloja la TUI (pane `fleetview`) o se llama `console`, se cierra solo el pane del target con `kill-pane` y la TUI sobrevive; el resto de windows siguen cerrándose con `kill-window`. Predicado interno `_fleet_window_hosts_tui` con tests. Es la causa raíz que complementa el auto-respawn de la TUI (`supervise_fleetview_tui`).
|
||||
- v1.0.0 — versión inicial.
|
||||
|
||||
@@ -26,6 +26,25 @@
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
# Predicado (puro respecto a tmux): dada una window — su nombre y el texto de sus
|
||||
# panes en formato "<pane_pid> <pane_current_command>" (una linea por pane) —
|
||||
# decide si esa window ALOJA la TUI fleetview o es la window 'console' del perfil.
|
||||
# Si es asi, cerrar la window entera con kill-window se llevaria la TUI por
|
||||
# delante; el caller debe cerrar solo el pane del target con kill-pane.
|
||||
# - Nombre de window 'console' = la window del panel FleetView por convencion
|
||||
# del launcher (y a donde el focus-swap ancla la TUI, ver fleetview v0.4.3).
|
||||
# - Algun pane corre el binario 'fleetview' (pane_current_command) = la TUI
|
||||
# vive ahi aunque la window se haya renombrado.
|
||||
# Devuelve 0 si aloja la TUI/console, 1 si no.
|
||||
_fleet_window_hosts_tui() {
|
||||
local window_name="${1:-}" panes_text="${2:-}"
|
||||
[[ "$window_name" == "console" ]] && return 0
|
||||
if printf '%s\n' "$panes_text" | awk '{print $2}' | grep -qx 'fleetview'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
kill_fleet_agent() {
|
||||
local target="" socket="" dry=0
|
||||
|
||||
@@ -155,27 +174,65 @@ USAGE
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resolver la window tmux del PID en el socket (pane_pid == claude por el
|
||||
# `exec claude` de spawn_fleet_agent). Best-effort: vacio si no hay socket.
|
||||
# Resolver la window tmux Y el pane del PID en el socket (pane_pid == claude
|
||||
# por el `exec claude` de spawn_fleet_agent). Capturamos window_id, pane_id y
|
||||
# window_name juntos. Best-effort: vacio si no hay socket.
|
||||
# -----------------------------------------------------------------------
|
||||
local window=""
|
||||
local window="" pane="" wname=""
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
window="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id}' 2>/dev/null \
|
||||
| awk -v p="$pid" '$1==p {print $2; exit}' || true)"
|
||||
local line
|
||||
line="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id} #{pane_id} #{window_name}' 2>/dev/null \
|
||||
| awk -v p="$pid" '$1==p {print $2, $3, $4; exit}' || true)"
|
||||
if [[ -n "$line" ]]; then
|
||||
window="$(awk '{print $1}' <<<"$line")"
|
||||
pane="$(awk '{print $2}' <<<"$line")"
|
||||
wname="$(awk '{print $3}' <<<"$line")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Guard 3 — anti-TUI/console: si la window del target aloja la TUI fleetview
|
||||
# o es la window 'console' del perfil, NO cerramos la window entera (eso se
|
||||
# llevaria la TUI), sino solo el pane del target con kill-pane. El layout
|
||||
# FleetView mete la TUI y un Claude en la misma window 'console', y los
|
||||
# focus-swaps (join-pane) pueden meter al ejecutor target en esa window.
|
||||
# -----------------------------------------------------------------------
|
||||
local hosts_tui=0
|
||||
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||
local panes_text
|
||||
panes_text="$(tmux -L "$socket" list-panes -t "$window" -F '#{pane_pid} #{pane_current_command}' 2>/dev/null || true)"
|
||||
if _fleet_window_hosts_tui "$wname" "$panes_text"; then
|
||||
hosts_tui=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Accion sobre la window/pane segun lo resuelto y el Guard 3.
|
||||
local action
|
||||
if [[ -z "$window" ]]; then
|
||||
action="solo SIGTERM (window no resuelta)"
|
||||
elif [[ "$hosts_tui" -eq 1 ]]; then
|
||||
if [[ -n "$pane" ]]; then
|
||||
action="kill-pane $pane (window '${wname:-$window}' aloja la TUI/console; se preserva la TUI)"
|
||||
else
|
||||
action="solo SIGTERM (window '${wname:-$window}' aloja la TUI y no se resolvio el pane; window preservada)"
|
||||
fi
|
||||
else
|
||||
action="kill-window $window"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Plan (se imprime siempre).
|
||||
# -----------------------------------------------------------------------
|
||||
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}"
|
||||
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)} pane: ${pane:-?} accion: $action"
|
||||
|
||||
if [[ "$dry" -eq 1 ]]; then
|
||||
echo "DRY-RUN: no se ha matado el proceso ni cerrado la window."
|
||||
echo "DRY-RUN: no se ha matado el proceso ni cerrado nada."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente).
|
||||
# Ejecutar: SIGTERM al claude (cierre limpio) + cierre de pane/window segun
|
||||
# el Guard 3 (idempotente).
|
||||
# -----------------------------------------------------------------------
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
@@ -185,9 +242,18 @@ USAGE
|
||||
fi
|
||||
|
||||
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||
if [[ "$hosts_tui" -eq 1 ]]; then
|
||||
if [[ -n "$pane" ]]; then
|
||||
tmux -L "$socket" kill-pane -t "$pane" 2>/dev/null || true
|
||||
echo "kill_fleet_agent: pane $pane cerrado (window '${wname:-$window}' aloja la TUI; window preservada)."
|
||||
else
|
||||
echo "kill_fleet_agent: window '${wname:-$window}' aloja la TUI pero no se resolvio el pane; solo SIGTERM (window preservada)."
|
||||
fi
|
||||
else
|
||||
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
|
||||
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -104,6 +104,24 @@ set -e
|
||||
assert_rc "error: sin target devuelve rc=2" 2 "$rc"
|
||||
assert_contains "error: mensaje falta target" "falta el target" "$out"
|
||||
|
||||
# --- Test 7 (Guard 3 predicado): _fleet_window_hosts_tui ---
|
||||
# La window 'console' SIEMPRE se considera que aloja la TUI (no se cierra entera).
|
||||
assert_predicate() {
|
||||
local test_name="$1" expected="$2"; shift 2
|
||||
set +e
|
||||
_fleet_window_hosts_tui "$@"; local rc=$?
|
||||
set -e
|
||||
assert_rc "$test_name" "$expected" "$rc"
|
||||
}
|
||||
# Nombre de window 'console' -> aloja TUI (rc 0), aunque ningun pane sea fleetview.
|
||||
assert_predicate "guard3: window 'console' aloja la TUI" 0 "console" $'1234 claude\n5678 bash'
|
||||
# Algun pane corre 'fleetview' -> aloja TUI (rc 0), aunque la window no sea console.
|
||||
assert_predicate "guard3: pane fleetview aloja la TUI" 0 "claude" $'1111 bash\n2222 fleetview'
|
||||
# Ni console ni fleetview -> NO aloja la TUI (rc 1): kill-window normal.
|
||||
assert_predicate "guard3: window normal no aloja la TUI" 1 "claude" $'3333 claude\n4444 bash'
|
||||
# Substring que contiene 'fleetview' pero no es el comando exacto -> NO matchea (grep -qx).
|
||||
assert_predicate "guard3: comando 'fleetviewer' no falsea positivo" 1 "work" $'7777 fleetviewer'
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
|
||||
@@ -3,10 +3,10 @@ name: launch_fleetclaude
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.4.0"
|
||||
version: "1.5.0"
|
||||
purity: impure
|
||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||
params:
|
||||
- name: --cwd
|
||||
@@ -20,7 +20,8 @@ params:
|
||||
- name: --cols
|
||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||
uses_functions: []
|
||||
uses_functions:
|
||||
- supervise_fleetview_tui_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -83,10 +84,20 @@ al retomar el trabajo en el repo `fn_registry`.
|
||||
TTY, reutiliza la terminal actual con `exec tmux attach`.
|
||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
||||
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
|
||||
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
|
||||
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
|
||||
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
||||
- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un
|
||||
`exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que
|
||||
relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se
|
||||
pierde por un fallo puntual. El supervisor para limpio con su sentinel
|
||||
(`touch ~/.claude/fleet/tui_stop_<perfil>` y deja salir la TUI) o se rinde si la
|
||||
TUI entra en crash-loop; en ambos casos el pane cae a una shell viva (no se
|
||||
cierra solo) para inspeccionar. Es la mitad "auto-recuperacion" del par de
|
||||
fixes que blindan FleetView; la otra es el Guard 3 anti-TUI/console de
|
||||
`kill_fleet_agent` (la causa raiz del cierre accidental). Si el script del
|
||||
supervisor no estuviera en disco, cae al `exec fleetview` clasico.
|
||||
- **`exec` en los demas panes**: `claude` (orquestador e idle) se lanza con
|
||||
`exec`, asi que al terminar el proceso el pane se cierra en vez de dejar una
|
||||
shell zombie. Excepcion: el fallback cuando `fleetview` no esta compilado deja
|
||||
una shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
||||
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
||||
@@ -113,6 +124,13 @@ al retomar el trabajo en el repo `fn_registry`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre
|
||||
`exec fleetview` (una sola vida), sino el bucle supervisor
|
||||
`supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su
|
||||
proceso o pane). Asi el panel de control NUNCA se pierde por un fallo puntual.
|
||||
Parada voluntaria via sentinel; crash-loop guard para no relanzar en bucle
|
||||
cerrado. Complementa el Guard 3 anti-TUI/console de `kill_fleet_agent` (causa
|
||||
raiz del cierre accidental). Nueva dependencia: `supervise_fleetview_tui_bash_infra`.
|
||||
- v1.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el
|
||||
fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/
|
||||
`--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...),
|
||||
|
||||
@@ -170,7 +170,22 @@ USAGE
|
||||
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
|
||||
local left_cmd
|
||||
if [[ -x "$bin" ]]; then
|
||||
# NO un `exec fleetview` de una sola vida: lo envolvemos en el bucle
|
||||
# supervisor supervise_fleetview_tui, que relanza la TUI si muere (crash,
|
||||
# panic, kill de su proceso o de su pane). Asi el panel de control de la
|
||||
# flota NUNCA se pierde por un fallo puntual. El supervisor para limpio
|
||||
# con su sentinel (touch ~/.claude/fleet/tui_stop_<perfil>) o se rinde si
|
||||
# la TUI entra en crash-loop; en ambos casos cae a una shell viva.
|
||||
local sup="$repo_root/bash/functions/infra/supervise_fleetview_tui.sh"
|
||||
if [[ -f "$sup" ]]; then
|
||||
# bash <sup> (no exec): al volver el supervisor (sentinel o crash-loop)
|
||||
# caemos a una shell viva para que el mensaje siga visible y se pueda
|
||||
# inspeccionar/relanzar. El env aplica al supervisor y a su hijo TUI.
|
||||
left_cmd="$envpfx bash $(printf '%q' "$sup") --bin $(printf '%q' "$bin") --socket $(printf '%q' "$session"); exec \"\$SHELL\""
|
||||
else
|
||||
# Fallback si falta el supervisor en disco: comportamiento clasico.
|
||||
left_cmd="$envpfx exec $(printf '%q' "$bin")"
|
||||
fi
|
||||
else
|
||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
||||
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: supervise_fleetview_tui
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "supervise_fleetview_tui --bin <path> [--socket <s>] [--sentinel <path>] [--backoff <s>] [--min-uptime <s>] [--max-fast-exits <n>]"
|
||||
description: "Bucle supervisor que mantiene viva la TUI fleetview: lanza el binario y, si sale (crash, panic, kill de su proceso o pane), lo relanza tras un backoff, para que el panel de control de la flota NUNCA se pierda por un fallo puntual. Es la pieza que hace resiliente al pane izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude). Dos valvulas de escape evitan el respawn infinito: un fichero centinela (touch <sentinel> => parada voluntaria al siguiente ciclo) y un crash-loop guard (si la TUI sale demasiado rapido muchas veces seguidas, el supervisor se rinde con rc=3 en vez de quemar CPU relanzando un binario roto)."
|
||||
tags: [fleet, claude-fleet, orchestration, fleetview, tui, supervisor, resilience, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
error_type: error_go_core
|
||||
file_path: "bash/functions/infra/supervise_fleetview_tui.sh"
|
||||
tested: true
|
||||
tests:
|
||||
- "golden: tras salir el binario, el supervisor lo relanza (respawn observable)"
|
||||
- "sentinel: tocar el fichero centinela para el bucle limpio (rc=0) y lo consume"
|
||||
- "crash-loop: salidas rapidas seguidas >= max_fast_exits hacen que se rinda (rc=3)"
|
||||
- "error: sin --bin rc=1; binario no ejecutable rc=1"
|
||||
test_file_path: "bash/functions/infra/supervise_fleetview_tui_test.sh"
|
||||
params:
|
||||
- name: --bin
|
||||
desc: "Ruta al binario fleetview a supervisar. Obligatorio. Si no es ejecutable, sale con rc=1 con instruccion de compilado."
|
||||
- name: --socket
|
||||
desc: "Socket del perfil FleetView. Solo fija el nombre del sentinel por defecto. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||
- name: --sentinel
|
||||
desc: "Ruta del fichero centinela de parada voluntaria. Si existe tras una salida de la TUI, se borra y el bucle termina. Default: $HOME/.claude/fleet/tui_stop_<socket>."
|
||||
- name: --backoff
|
||||
desc: "Segundos de espera antes de relanzar la TUI tras una salida. Default: 1."
|
||||
- name: --min-uptime
|
||||
desc: "Umbral en segundos para considerar una salida 'rapida' (sospecha de crash-loop). Un arranque que dura >= este valor resetea el contador. Default: 2."
|
||||
- name: --max-fast-exits
|
||||
desc: "Numero de salidas rapidas seguidas tras las que el supervisor se rinde (crash-loop guard) en vez de seguir relanzando. Default: 5."
|
||||
output: "No retorna valor; corre indefinidamente relanzando la TUI. Sale 0 ante parada voluntaria (sentinel), 1 ante uso incorrecto / binario no ejecutable, 3 cuando el crash-loop guard se rinde. Imprime una linea por cada relanzamiento o parada."
|
||||
---
|
||||
|
||||
# supervise_fleetview_tui
|
||||
|
||||
Bucle supervisor de la TUI `fleetview`. Corre el binario y, cada vez que sale (crash, panic, `kill` de su proceso, cierre de su pane), lo **relanza** tras un pequeño backoff. Hace que el panel de control de la flota — el pane izquierdo de la sesión tmux FleetView — **nunca se pierda** por un fallo puntual. `launch_fleetclaude` lo usa como comando del pane izquierdo en vez de un `exec fleetview` de una sola vida.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Como lo invoca el launcher en el pane izquierdo (relanza la TUI si muere):
|
||||
FLEET_SOCKET=fleet bash bash/functions/infra/supervise_fleetview_tui.sh \
|
||||
--bin apps/fleetview/fleetview --socket fleet
|
||||
|
||||
# Pararlo voluntariamente desde otra terminal: tocar el sentinel y dejar salir la TUI.
|
||||
touch ~/.claude/fleet/tui_stop_fleet
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala como wrapper del binario `fleetview` siempre que quieras que la TUI sobreviva a un crash o a un `kill` accidental de su proceso/pane (p. ej. un `kill_fleet_agent` que cierre la window que la aloja). Es la mitad "auto-recuperación" del par de fixes que blindan FleetView; la otra mitad es el Guard 3 anti-TUI/console de `kill_fleet_agent` (la causa raíz). No la uses para supervisar Claudes (esos se relanzan con `claude --resume`, no en bucle ciego).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura y de larga duración**: corre indefinidamente. Está pensada para vivir en un pane tmux con TTY, no como systemd service (la TUI necesita PTY; el watcher de fleetview sí es systemd `Restart=always`).
|
||||
- **Crash-loop guard**: si la TUI sale en menos de `--min-uptime` segundos, `--max-fast-exits` veces seguidas, el supervisor se **rinde** (rc=3) en vez de relanzar para siempre un binario roto. Ajusta los umbrales si tu arranque es legítimamente lento.
|
||||
- **Sentinel = única parada voluntaria limpia**: `touch <sentinel>` y deja que la TUI salga; al siguiente ciclo el supervisor ve el fichero, lo borra y termina. Sin sentinel, **relanza siempre** (es el objetivo: que no se pierda). Un sentinel huérfano de una sesión previa se limpia al arrancar para no parar de inmediato.
|
||||
- **El sentinel por defecto depende del socket**: `~/.claude/fleet/tui_stop_<socket>`. Dos perfiles (`fleet`, `fleet2`) tienen sentinels distintos, así parar uno no para el otro.
|
||||
- **No supervisa Claudes**: su contrato es solo la TUI. Relanzar un Claude en bucle ciego perdería su sesión; los Claudes se recuperan con `claude --resume`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(v1.0.0 — sin cambios todavía.)
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# supervise_fleetview_tui — bucle supervisor que mantiene viva la TUI fleetview.
|
||||
#
|
||||
# Lanza el binario fleetview y, si sale (crash, panic, kill de su proceso o de su
|
||||
# pane), lo relanza tras un pequeno backoff. Asi el panel de control de la flota
|
||||
# NUNCA se pierde por un fallo puntual: es la pieza que hace resiliente al pane
|
||||
# izquierdo de la sesion tmux FleetView (lo invoca launch_fleetclaude).
|
||||
#
|
||||
# Dos valvulas de escape para no hacer respawn infinito:
|
||||
# - Sentinel file: si tras una salida existe el fichero centinela, se borra y
|
||||
# el bucle termina (parada voluntaria solicitada por el usuario). El default
|
||||
# es $HOME/.claude/fleet/tui_stop_<socket>; pararla a mano: `touch <sentinel>`
|
||||
# y dejar que la TUI salga (o matar su proceso).
|
||||
# - Crash-loop guard: si la TUI sale demasiado rapido (uptime < min_uptime
|
||||
# segundos) muchas veces seguidas (>= max_fast_exits), el supervisor se rinde
|
||||
# y devuelve != 0, para no quemar CPU relanzando un binario roto en caliente.
|
||||
# Un arranque que dura >= min_uptime resetea el contador.
|
||||
#
|
||||
# Funcion IMPURA: lanza un proceso en bucle y lee/escribe un fichero centinela.
|
||||
#
|
||||
# Overrides de entorno (testabilidad, no para uso normal):
|
||||
# FLEET_SOCKET socket del perfil; fija el nombre del sentinel por defecto.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
supervise_fleetview_tui() {
|
||||
local bin="" socket="" sentinel="" backoff=1 min_uptime=2 max_fast_exits=5
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bin) shift; bin="${1:-}" ;;
|
||||
--socket) shift; socket="${1:-}" ;;
|
||||
--sentinel) shift; sentinel="${1:-}" ;;
|
||||
--backoff) shift; backoff="${1:-1}" ;;
|
||||
--min-uptime) shift; min_uptime="${1:-2}" ;;
|
||||
--max-fast-exits) shift; max_fast_exits="${1:-5}" ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: supervise_fleetview_tui --bin <path> [opciones]
|
||||
|
||||
Bucle supervisor: corre el binario fleetview y lo relanza si sale, para que el
|
||||
panel de la flota nunca se pierda por un crash/kill puntual.
|
||||
|
||||
Opciones:
|
||||
--bin <path> Ruta al binario fleetview (obligatorio).
|
||||
--socket <s> Socket del perfil FleetView. Default: $FLEET_SOCKET o "fleet".
|
||||
--sentinel <path> Fichero centinela de parada voluntaria.
|
||||
Default: $HOME/.claude/fleet/tui_stop_<socket>.
|
||||
--backoff <s> Segundos de espera antes de relanzar. Default: 1.
|
||||
--min-uptime <s> Umbral (s) para considerar una salida "rapida". Default: 2.
|
||||
--max-fast-exits <n> Salidas rapidas seguidas tras las que el supervisor se
|
||||
rinde (crash-loop guard). Default: 5.
|
||||
-h, --help Esta ayuda.
|
||||
|
||||
Parar el bucle a mano: `touch <sentinel>` y dejar que la TUI salga (o matar su
|
||||
proceso); en el siguiente ciclo el supervisor ve el sentinel, lo borra y termina.
|
||||
|
||||
Salida: 0 parada voluntaria (sentinel); 1 binario no ejecutable / uso incorrecto;
|
||||
3 el supervisor se rindio por crash-loop (demasiadas salidas rapidas seguidas).
|
||||
USAGE
|
||||
return 0 ;;
|
||||
--*)
|
||||
echo "supervise_fleetview_tui: opcion desconocida '$1' (usa -h)" >&2
|
||||
return 1 ;;
|
||||
*)
|
||||
if [[ -z "$bin" ]]; then
|
||||
bin="$1"
|
||||
else
|
||||
echo "supervise_fleetview_tui: argumento extra '$1' (bin ya es '$bin')" >&2
|
||||
return 1
|
||||
fi ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -z "$bin" ]] && {
|
||||
echo "supervise_fleetview_tui: falta --bin <path> al binario fleetview. Usa -h." >&2
|
||||
return 1
|
||||
}
|
||||
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||
[[ -z "$sentinel" ]] && sentinel="$HOME/.claude/fleet/tui_stop_${socket}"
|
||||
mkdir -p "$(dirname "$sentinel")" 2>/dev/null || true
|
||||
|
||||
if [[ ! -x "$bin" ]]; then
|
||||
echo "supervise_fleetview_tui: binario '$bin' no es ejecutable. Compila la TUI: cd apps/fleetview && go build -o fleetview ." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Limpiar un sentinel huerfano de una sesion anterior, para no parar al arrancar.
|
||||
[[ -f "$sentinel" ]] && rm -f "$sentinel" 2>/dev/null || true
|
||||
|
||||
local fast_exits=0
|
||||
while true; do
|
||||
local start end uptime code
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
"$bin"
|
||||
code=$?
|
||||
set -e
|
||||
end=$(date +%s)
|
||||
uptime=$(( end - start ))
|
||||
|
||||
# Valvula 1 — parada voluntaria por sentinel.
|
||||
if [[ -f "$sentinel" ]]; then
|
||||
rm -f "$sentinel" 2>/dev/null || true
|
||||
echo "[fleetview: parada solicitada via sentinel ($sentinel) — fin del supervisor]"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Valvula 2 — crash-loop guard.
|
||||
if [[ "$uptime" -lt "$min_uptime" ]]; then
|
||||
fast_exits=$(( fast_exits + 1 ))
|
||||
else
|
||||
fast_exits=0
|
||||
fi
|
||||
if [[ "$fast_exits" -ge "$max_fast_exits" ]]; then
|
||||
echo "[fleetview: $fast_exits salidas rapidas seguidas (ultimo code=$code) — el supervisor se rinde para no hacer respawn infinito. Inspecciona el binario y relanza.]" >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
echo "[fleetview salio (code=$code, uptime=${uptime}s) — relanzando en ${backoff}s. Para parar: touch $sentinel, o Ctrl-C.]"
|
||||
sleep "$backoff"
|
||||
done
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
supervise_fleetview_tui "$@"
|
||||
fi
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para supervise_fleetview_tui. Usa un binario falso (un script) que cuenta
|
||||
# sus invocaciones, para verificar respawn, crash-loop guard y sentinel sin correr
|
||||
# la TUI real.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/supervise_fleetview_tui.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local test_name="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
echo "PASS: $test_name ($actual)"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected '$expected', got '$actual'"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
COUNTER="$TMP/runs"
|
||||
SENTINEL="$TMP/sentinel"
|
||||
|
||||
# --- Test 1 (crash-loop guard): binario que sale rapido siempre se rinde a las N ---
|
||||
# Fake bin: registra una linea por invocacion y sale 1 inmediato.
|
||||
FAKE_FAST="$TMP/fake_fast.sh"
|
||||
cat > "$FAKE_FAST" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo run >> "$COUNTER"
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$FAKE_FAST"
|
||||
|
||||
: > "$COUNTER"
|
||||
set +e
|
||||
out=$(supervise_fleetview_tui --bin "$FAKE_FAST" --backoff 0 --min-uptime 100 \
|
||||
--max-fast-exits 3 --sentinel "$SENTINEL" 2>&1); rc=$?
|
||||
set -e
|
||||
runs=$(wc -l < "$COUNTER" | tr -d ' ')
|
||||
assert_eq "crash-loop: se rinde con rc=3" 3 "$rc"
|
||||
assert_eq "crash-loop: corrio exactamente 3 veces" 3 "$runs"
|
||||
assert_contains "crash-loop: mensaje de rendicion" "el supervisor se rinde" "$out"
|
||||
|
||||
# --- Test 2 (golden respawn + sentinel): relanza tras salir, para via sentinel ---
|
||||
# Fake bin: en la 2a invocacion crea el sentinel, luego sale. Prueba que:
|
||||
# (a) tras la 1a salida RELANZA (respawn) -> hay 2a invocacion (golden).
|
||||
# (b) al ver el sentinel, PARA (no hay 3a invocacion).
|
||||
FAKE_SENT="$TMP/fake_sent.sh"
|
||||
cat > "$FAKE_SENT" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
echo run >> "$COUNTER"
|
||||
n=\$(wc -l < "$COUNTER" | tr -d ' ')
|
||||
if [[ "\$n" -ge 2 ]]; then
|
||||
touch "$SENTINEL"
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$FAKE_SENT"
|
||||
|
||||
: > "$COUNTER"
|
||||
rm -f "$SENTINEL"
|
||||
set +e
|
||||
out=$(supervise_fleetview_tui --bin "$FAKE_SENT" --backoff 0 --min-uptime 0 \
|
||||
--max-fast-exits 99 --sentinel "$SENTINEL" 2>&1); rc=$?
|
||||
set -e
|
||||
runs=$(wc -l < "$COUNTER" | tr -d ' ')
|
||||
assert_eq "golden: relanzo tras morir (2 invocaciones)" 2 "$runs"
|
||||
assert_eq "sentinel: para limpio con rc=0" 0 "$rc"
|
||||
assert_contains "sentinel: mensaje de parada voluntaria" "parada solicitada via sentinel" "$out"
|
||||
assert_eq "sentinel: el fichero se consume (borrado)" "no" "$([[ -f "$SENTINEL" ]] && echo si || echo no)"
|
||||
assert_contains "golden: registra el respawn" "relanzando" "$out"
|
||||
|
||||
# --- Test 3 (error): falta --bin ---
|
||||
set +e
|
||||
out=$(supervise_fleetview_tui --backoff 0 2>&1); rc=$?
|
||||
set -e
|
||||
assert_eq "error: sin --bin devuelve rc=1" 1 "$rc"
|
||||
assert_contains "error: mensaje falta --bin" "falta --bin" "$out"
|
||||
|
||||
# --- Test 4 (error): binario no ejecutable ---
|
||||
set +e
|
||||
out=$(supervise_fleetview_tui --bin "$TMP/no_existe" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_eq "error: binario no ejecutable rc=1" 1 "$rc"
|
||||
assert_contains "error: mensaje no ejecutable" "no es ejecutable" "$out"
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from ab38127ac0
@@ -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
|
||||
|
||||
@@ -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,10 @@ 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-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
||||
| [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 |
|
||||
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# comfyui-judge — panel multi-juez de calidad de imagen
|
||||
|
||||
El "modelo adversario" del pipeline ComfyUI: el sistema que distingue **producto bueno de
|
||||
malo** de forma objetiva. Tres jueces independientes puntúan/critican una imagen y un
|
||||
agregador vota por mayoría. Es el **gate objetivo** que consumen los tests, los contratos
|
||||
DoD y el bucle de mejora de skills (grupo `comfyui-skill`): un skill no está "hecho" hasta
|
||||
que la imagen que produce pasa el panel (`verdict == 'good'`).
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_score_aesthetic_py_ml` | `score_aesthetic(image_path) -> {ok, score_0_10}` | Calidad estética LAION-V2 (head MLP sobre CLIP ViT-L/14). Subproceso al venv ComfyUI. Barato, determinista, sin API. |
|
||||
| `comfyui_score_clip_alignment_py_ml` | `score_clip_alignment(image_path, prompt) -> {ok, score_0_1}` | Fidelidad prompt↔imagen (similitud coseno CLIP). Subproceso al venv ComfyUI. |
|
||||
| `comfyui_critique_image_llm_py_ml` | `critique_image_llm(image_path, prompt) -> {ok, verdict, score_0_10, reasons}` | Crítica de un LLM-vision (artefactos, anatomía, watermarks). Compone `ask_llm_vision` (claude-direct). Cuesta API. |
|
||||
| `comfyui_judge_image_py_ml` | `judge_image(image_path, prompt) -> {ok, verdict, score, votes, reasons}` | **Agregadora.** Llama a los 3, vota good/bad por mayoría, agrega razones. Degrada si un juez cae. |
|
||||
|
||||
Los tres jueces son ortogonales a propósito: el estético mide *belleza*, el de fidelidad mide
|
||||
*que sea lo pedido*, y el crítico LLM ve *defectos finos* que un score global no penaliza. Un
|
||||
único score se engaña fácil; tres votos independientes, no.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_judge_image import comfyui_judge_image
|
||||
|
||||
img = os.path.expanduser("~/ComfyUI/output/comfy_sdxl_00001_.png")
|
||||
prompt = "a majestic lion standing on rocks at sunset, photorealistic"
|
||||
|
||||
res = comfyui_judge_image(img, prompt)
|
||||
print(res["verdict"], round(res["score"], 2), res["votes"])
|
||||
# good 7.1 {'aesthetic': 'good', 'clip': 'good', 'llm': 'good'}
|
||||
|
||||
if res["verdict"] != "good":
|
||||
for r in res["reasons"]:
|
||||
print(" -", r) # feedback accionable para mejorar el skill
|
||||
```
|
||||
|
||||
Jueces sueltos (cuando solo quieres una dimensión):
|
||||
|
||||
```bash
|
||||
# estético (sin API, rápido)
|
||||
python/.venv/bin/python3 python/functions/ml/comfyui_score_aesthetic.py ~/ComfyUI/output/comfy_sdxl_00001_.png
|
||||
# fidelidad
|
||||
python/.venv/bin/python3 python/functions/ml/comfyui_score_clip_alignment.py ~/ComfyUI/output/comfy_sdxl_00001_.png "a lion at sunset"
|
||||
```
|
||||
|
||||
## Cómo se enchufa a tests / DoD como gate objetivo
|
||||
|
||||
- **e2e_check de un skill ComfyUI**: declarar en el `app.md` (o en el contrato del skill) un
|
||||
check que genere la imagen y exija `comfyui_judge_image(img, prompt)["verdict"] == "good"`.
|
||||
Es la cláusula golden: producto = imagen que el panel aprueba, no "el workflow no petó".
|
||||
- **Bucle de mejora** (grupo `comfyui-skill`): tras generar, juzgar; si `verdict == 'bad'`,
|
||||
las `reasons` del juez crítico indican qué corregir (más steps, otro sampler, fix de prompt)
|
||||
y se re-genera. Convergencia = el panel aprueba.
|
||||
- **Selección de la mejor de N**: ejecutar el panel sobre cada candidata y rankear por `score`
|
||||
(o filtrar por `verdict == 'good'`).
|
||||
|
||||
Umbrales por defecto (ajustables): estético `>= 6.0` → good; fidelidad `>= 0.24` → good; el
|
||||
crítico da su propio verdict. Veredicto final = **mayoría** de los votos vivos; empate → `bad`.
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No genera imágenes.** Eso es el grupo `comfyui` (build_*_workflow + submit + wait + fetch).
|
||||
Este grupo solo *juzga* una imagen ya producida.
|
||||
- **No es un detector forense de IA** ni un clasificador NSFW/seguridad: juzga *calidad de
|
||||
producto*, no procedencia ni políticas de contenido.
|
||||
- **No corre en el venv del registry.** Los jueces estético/fidelidad necesitan torch +
|
||||
open_clip, que viven en `~/ComfyUI/.venv`; se invocan por subproceso. El crítico necesita la
|
||||
API Anthropic (claude-direct).
|
||||
- **No persiste resultados.** Devuelve dicts en memoria; persistir veredictos (operations.db,
|
||||
e2e_runs) es responsabilidad del consumidor.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- venv de ComfyUI con torch + open_clip 3.x: `~/ComfyUI/.venv/bin/python3`.
|
||||
- Modelo estético LAION en `/mnt/2tb/comfyui_models/aesthetic/sac+logos+ava1-l14-linearMSE.pth`.
|
||||
- CLIP ViT-L-14-quickgelu (pretrained openai) cacheado (se descarga la 1ª vez, ~900 MB).
|
||||
- Token OAuth de Claude (claude-direct) para el juez crítico — lo resuelve `ask_llm_vision`.
|
||||
|
||||
## Notas
|
||||
|
||||
- **QuickGELU** es obligatorio en CLIP (`ViT-L-14-quickgelu`/`openai`): sin él los embeddings
|
||||
se degradan en silencio y tanto el score estético como el ranking de fidelidad se desvirtúan.
|
||||
- El panel **degrada con gracia**: si un juez cae (p.ej. el LLM en HTTP 429), vota con los
|
||||
demás y lo anota; solo falla si caen los tres.
|
||||
@@ -0,0 +1,125 @@
|
||||
# ComfyUI — mapa de capacidades de generación
|
||||
|
||||
Vista de pájaro de **qué sabemos generar con ComfyUI**, organizada por capacidad. Es el índice de
|
||||
entrada al stack ComfyUI: cruza cada capacidad con las funciones del registry que la implementan,
|
||||
los grafos UI cargables y las skills (recetas) que la materializan.
|
||||
|
||||
Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
|
||||
|
||||
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
||||
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
||||
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||
|
||||
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
||||
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
||||
su contraparte versionable: el mapa capacidad → grupo de funciones que sí pertenece a `fn_registry`.
|
||||
|
||||
Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-skill"`,
|
||||
`tag="comfyui-judge"`).
|
||||
|
||||
## Las capacidades de un vistazo
|
||||
|
||||
| # | Capacidad | Qué resuelve | Builders / pipelines clave | Grafo UI | Skills |
|
||||
|---|---|---|---|---|---|
|
||||
| 01 | **txt2img** | prompt → imagen (SD1.5/SDXL/Flux) | `build_txt2img`, `build_flux`, `build_sdxl_refiner`, `txt2img_oneshot` | ✅ ×2 | — |
|
||||
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
||||
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
||||
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
||||
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
||||
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
||||
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
||||
| 09 | **operación / infra** | server, modelos, cola, encolar/esperar, I/O de workflows | `ensure_server`, `submit_workflow`, `wait_result`, `validate_workflow`, `download_model`, `run_foreign_workflow_oneshot` | — | — |
|
||||
| 10 | **ipadapter / referencia** _(en construcción)_ | guiar por imagen de referencia (estilo/sujeto) sin LoRA + encadenar varios LoRAs en una llamada | `build_ipadapter_workflow`, `inject_multi_lora` _(añadiéndose ahora; no indexadas todavía)_ | — | — |
|
||||
|
||||
Las capacidades 01-05 ya tienen grafo UI cargable; 06-08 están cubiertas por funciones del registry
|
||||
pero sin grafo UI todavía (se añade su subcarpeta cuando aparezca el primero). 09 es la maquinaria
|
||||
transversal que habilita el resto, no una capacidad de generación. **10 está en construcción** por
|
||||
el flujo de funciones+server (al 24/06/2026 vi `comfyui_build_ipadapter_workflow` e
|
||||
`comfyui_inject_multi_lora` en `python/functions/ml/` sin indexar aún): se completará este mapa con
|
||||
sus IDs reales cuando se ejecute `fn index`.
|
||||
|
||||
## Mapa capacidad → funciones del registry
|
||||
|
||||
### 01 · txt2img
|
||||
|
||||
- `comfyui_build_txt2img_workflow_py_ml` (pura) — SD1.5/SDXL: CheckpointLoader → CLIPTextEncode×2 → KSampler → VAEDecode → SaveImage.
|
||||
- `comfyui_build_flux_workflow_py_ml` (pura) — Flux: UNETLoader + DualCLIPLoader + VAELoader, guía por FluxGuidance.
|
||||
- `comfyui_build_sdxl_refiner_workflow_py_ml` (pura) — SDXL base+refiner (2 KSamplerAdvanced encadenados).
|
||||
- `comfyui_txt2img_oneshot_py_pipelines` — prompt → PNG en disco (build + submit + wait + fetch).
|
||||
|
||||
### 02 · img2img / inpaint
|
||||
|
||||
- `comfyui_build_img2img_workflow_py_ml` (pura) — LoadImage → VAEEncode → KSampler (denoise<1).
|
||||
- `comfyui_build_inpaint_workflow_py_ml` (pura) — LoadImage + LoadImageMask → VAEEncodeForInpaint.
|
||||
|
||||
### 03 · controlnet
|
||||
|
||||
- `comfyui_build_controlnet_workflow_py_ml` (pura) — ControlNetLoader → ControlNetApply sobre el condicionamiento positivo.
|
||||
|
||||
### 04 · skills (multiestilo / LoRA)
|
||||
|
||||
- `comfyui_build_skill_workflow_py_ml` (pura) — compila una receta (`recipe.json`) a workflow, sustituye `{subject}`, encadena LoRAs + post-proceso.
|
||||
- `comfyui_inject_lora_py_ml` (pura) — inserta `LoraLoader` en un workflow ya construido (encadenable).
|
||||
- `comfyui_generate_with_skill_oneshot_py_pipelines` — skill + subject → PNG juzgado.
|
||||
- `comfyui_harvest_civitai_skill_oneshot_py_pipelines` — Civitai → skill candidata.
|
||||
- `comfyui_export_skill_template_py_ml` — skill → template API + grafo UI cargable.
|
||||
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
||||
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
||||
|
||||
### 05 · video
|
||||
|
||||
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
||||
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
|
||||
|
||||
### 06 · upscale / detail
|
||||
|
||||
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
|
||||
- `comfyui_build_hires_fix_workflow_py_ml` (pura) — hires-fix 2 pasadas (UltimateSDUpscale por tiles + Remacri).
|
||||
- `comfyui_inject_hires_fix_py_ml` (pura) — inyecta la 2ª pasada en un workflow ya construido.
|
||||
- `comfyui_build_facedetailer_workflow_py_ml` (pura) — FaceDetailer (detecta caras YOLO y las regenera).
|
||||
|
||||
### 07 · 3D
|
||||
|
||||
- `comfyui_build_image_to_3d_workflow_py_ml` (pura) — imagen → GLB (Hunyuan3D-2 nativo, 9 nodos).
|
||||
- `comfyui_build_textured_3d_multiview_workflow_py_ml` (pura) — malla texturizada PBR multi-vista (Hunyuan3DWrapper).
|
||||
- `comfyui_build_view_3d_workflow_py_ml` (pura) — visor 3D nativo (Load3D) para orbitar un GLB existente.
|
||||
- `comfyui_generate_views_from_image_py_ml` — sintetiza vistas novel-view (StableZero123/SV3D).
|
||||
- Pipelines: `comfyui_image_to_3d_oneshot_py_pipelines`, `comfyui_text_to_3d_oneshot_py_pipelines`, `comfyui_mesh_cleanup_oneshot_py_pipelines`.
|
||||
- Mallas: `comfyui_simplify_mesh_py_ml`, `comfyui_make_watertight_py_ml`, `comfyui_install_3d_model_py_ml`.
|
||||
- Relación: el grupo [img-to-3d](img-to-3d.md) es la vía ligera (relieve por profundidad) que produce el GLB que [mesh-3d](mesh-3d.md) renderiza; ComfyUI/Hunyuan3D es la vía pesada de malla volumétrica real.
|
||||
|
||||
### 08 · juez / calidad
|
||||
|
||||
- `comfyui_judge_image_py_ml` — panel agregador (estético + CLIP + LLM-vision), veredicto por mayoría.
|
||||
- `comfyui_score_aesthetic_py_ml` — score estético LAION-V2 (0-10).
|
||||
- `comfyui_score_clip_alignment_py_ml` — fidelidad prompt↔imagen vía CLIP (0-1).
|
||||
- `comfyui_critique_image_llm_py_ml` — crítica LLM-vision (artefactos, anatomía, texto, watermarks).
|
||||
|
||||
### 09 · operación / infra (transversal)
|
||||
|
||||
- Server: `comfyui_ensure_server_py_infra`.
|
||||
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
|
||||
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
|
||||
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
|
||||
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`.
|
||||
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
|
||||
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
|
||||
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
|
||||
|
||||
## Librería de grafos en disco
|
||||
|
||||
Los grafos UI cargables viven en `~/ComfyUI/user/default/workflows/`, agrupados en subcarpetas
|
||||
numeradas por capacidad (`01_txt2img/`, `02_img2img/`, `03_controlnet/`, `04_skills/`, `05_video/`).
|
||||
ComfyUI las lista recursivamente (`GET /api/userdata?dir=workflows&recurse=true`) y el menú
|
||||
**Workflows** del navegador las muestra como árbol anidado, así que las capacidades quedan visibles
|
||||
en la propia UI. La regla de clasificación de un grafo nuevo (por su nodo terminal) y el detalle de
|
||||
cada grafo concreto están en `~/ComfyUI/CAPABILITIES.md`.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- Este doc es un **índice cross-grupo**: no documenta firmas completas ni gotchas por función — eso
|
||||
vive en las páginas madre (`comfyui.md`, `comfyui-skill.md`, `comfyui-judge.md`) y en cada `.md`
|
||||
de función. Aquí solo está el mapa capacidad → función.
|
||||
- No cubre la **generación** en sí (ejecutar workflows contra la GPU); cubre el catálogo de qué
|
||||
capacidades existen y con qué piezas se componen.
|
||||
@@ -0,0 +1,350 @@
|
||||
# ComfyUI Skill — Recetas versionadas de generación reutilizables
|
||||
|
||||
Tag: `comfyui-skill`. Grupo para tratar una configuración de generación de ComfyUI como una
|
||||
**skill**: una receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt +
|
||||
bloques de post-proceso) que se guarda una vez y se reproduce a un workflow concreto cambiando
|
||||
solo el *subject*. Es la doctrina del issue 0087 aplicada a la generación de imágenes: el registry
|
||||
crece **promoviendo configuraciones que funcionan a recetas reutilizables**, no reescribiendo el
|
||||
grafo de nodos cada vez.
|
||||
|
||||
Construye sobre el grupo [`comfyui`](comfyui.md) (los builders puros de workflow y el ciclo
|
||||
submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
||||
|
||||
## Qué es una skill
|
||||
|
||||
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
||||
|
||||
```
|
||||
~/ComfyUI/skills_library/
|
||||
INDEX.md # índice regenerado de todas las skills
|
||||
<slug>/
|
||||
recipe.json # la receta actual
|
||||
versions/vN.json # snapshot inmutable de cada save (N incremental)
|
||||
growth_log.jsonl # bitácora append-only de cada save
|
||||
exports/ # plantillas de workflow exportadas
|
||||
samples/ # imágenes de muestra
|
||||
```
|
||||
|
||||
### Schema de `recipe.json` (canónico)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"slug": "portrait_cinematic_sdxl",
|
||||
"version": "1.0.0",
|
||||
"title": "Retrato cinematográfico SDXL",
|
||||
"base_workflow": "txt2img",
|
||||
"checkpoint": "juggernaut_xl_v11.safetensors",
|
||||
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras", "width": 832, "height": 1216, "denoise": 1.0},
|
||||
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||
"negative": "blurry, lowres", "trigger_words": []},
|
||||
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}},
|
||||
{"type": "hires_fix", "params": {"upscale_by": 1.5, "denoise": 0.4}}],
|
||||
"score_mean": 0.0, "score_n": 0,
|
||||
"provenance": {"source": "manual", "nsfw": false},
|
||||
"export_template_path": "exports/portrait_cinematic_sdxl.template.json"
|
||||
}
|
||||
```
|
||||
|
||||
`base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`} (las bases que se generan desde un *subject*
|
||||
de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}.
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace | Purity |
|
||||
|---|---|---|---|
|
||||
| [comfyui_build_skill_workflow_py_ml](../../python/functions/ml/comfyui_build_skill_workflow.md) | `build_skill_workflow(recipe, subject, *, seed=0) -> dict` | Compila una receta a un workflow en API format: despacha al builder base, sustituye `{subject}` + trigger_words, encadena LoRAs y aplica los blocks en orden. `SkillWorkflowError` si la base es desconocida o requiere imagen. | **pura** |
|
||||
| [comfyui_export_skill_template_py_ml](../../python/functions/ml/comfyui_export_skill_template.md) | `export_skill_template(slug, *, ui_graph=False, port=9222, ...) -> dict` | Exporta una skill a artefactos cargables como GRAFO: template API en `exports/<slug>.template.json` y, con `ui_graph=True`, el UI graph posicionado (vía `load_workflow_ui`+`export_workflow_ui` por CDP) en la carpeta nativa `~/ComfyUI/user/default/workflows/<slug>.json` (menú Workflows del navegador). Sin navegador, deja el template API y reporta el fallback. | impura |
|
||||
| [comfyui_inject_hires_fix_py_ml](../../python/functions/ml/comfyui_inject_hires_fix.md) | `comfyui_inject_hires_fix(workflow, *, upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Inyecta una 2ª pasada hires-fix (UpscaleModelLoader + UltimateSDUpscale) sobre un workflow ya construido, repuntando el SaveImage. Versión encadenable-sobre-dict del builder hermano. | **pura** |
|
||||
| [comfyui_inject_multi_lora_py_ml](../../python/functions/ml/comfyui_inject_multi_lora.md) | `comfyui_inject_multi_lora(workflow, loras) -> dict` | Encadena N `LoraLoader` sobre un workflow ya construido reusando `comfyui_inject_lora` por LoRA. Cada lora = `{name, strength_model, strength_clip}`; respeta el orden (primero cerca del checkpoint, último cerca del KSampler). Apila estilo + detalle en una sola llamada. | **pura** |
|
||||
| [comfyui_build_ipadapter_workflow_py_ml](../../python/functions/ml/comfyui_build_ipadapter_workflow.md) | `comfyui_build_ipadapter_workflow(prompt, ref_image, *, base_checkpoint, mode='style'\|'faceid', weight=0.8, ...) -> dict` | txt2img + IPAdapter (custom node cubiq). `mode='style'` transfiere estilo/composición de una imagen de referencia (IPAdapterUnifiedLoader+IPAdapter); `mode='faceid'` impone un rostro consistente vía insightface + .bin FaceID + su LoRA (IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID). Repunta el KSampler a la rama IPAdapter. | **pura** |
|
||||
| [comfyui_save_skill_py_ml](../../python/functions/ml/comfyui_save_skill.md) | `comfyui_save_skill(recipe, *, library_dir=None) -> dict` | Valida el schema mínimo y escribe `recipe.json` + snapshot `versions/vN.json` + growth_log + INDEX.md. No muta la receta (round-trip con load). | impura |
|
||||
| [comfyui_load_skill_py_ml](../../python/functions/ml/comfyui_load_skill.md) | `comfyui_load_skill(slug, *, version=None, library_dir=None) -> dict` | Lee `recipe.json` (actual) o un snapshot `versions/vN.json`. Slug/versión inexistente → `{ok:False}` sin lanzar. | impura |
|
||||
| [comfyui_list_skills_py_ml](../../python/functions/ml/comfyui_list_skills.md) | `comfyui_list_skills(*, library_dir=None, include_nsfw=False) -> dict` | Lista las skills con slug/title/base_workflow/version/score/nsfw/n_versions. Oculta NSFW por defecto. | impura |
|
||||
| [ask_llm_vision_py_core](../../python/functions/core/ask_llm_vision.md) | `ask_llm_vision(prompt, image_path='', *, image_b64='', media_type='', model='claude-opus-4-8', ...) -> dict` | Pregunta multimodal (imagen + texto) al modelo via API directa de Anthropic (grupo `claude-direct`). Útil para **puntuar** el PNG de una skill y alimentar `score_mean`. | impura |
|
||||
| [comfyui_generate_with_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_generate_with_skill_oneshot.md) | `generate_with_skill_oneshot(slug, subject, *, server='127.0.0.1:8188', dest=None, seed=0, judge=True, recipe_patch=None, ...) -> dict` | One-shot del bucle: carga la skill, la compila para el `subject`, encola, espera, descarga el PNG y (si `judge`) lo puntúa con el panel `comfyui-judge`, acumulando el score en la media. `recipe_patch` prueba una variante en memoria sin guardar. | pipeline (impura) |
|
||||
| [comfyui_update_skill_score_py_ml](../../python/functions/ml/comfyui_update_skill_score.md) | `comfyui_update_skill_score(slug, new_score, *, library_dir=None) -> dict` | Acumula el score de un juicio en `score_mean`/`score_n` por media incremental, reescribiendo `recipe.json` en sitio (sin snapshot ni growth_log). | impura |
|
||||
| [comfyui_bump_skill_version_py_ml](../../python/functions/ml/comfyui_bump_skill_version.md) | `comfyui_bump_skill_version(slug, change, *, score_before, score_after, judge_run_id=None, recipe_patch=None, force=False, ...) -> dict` | Promueve una versión nueva **solo si el score sube** (gate objetivo): snapshot `versions/vN.json` + aplica `recipe_patch` + sube el semver + línea en `growth_log`. Gate bloquea si no mejora. | impura |
|
||||
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `comfyui_extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado de Civitai en una receta de skill **candidata** (`score_n=0`, `provenance.source='civitai'`). Compone `comfyui_import_workflow_png` (workflow API embebido) + `comfyui_read_png_metadata` (params del KSampler); fallback a la `meta` de Civitai. Degradación honesta: `ok=False` sin inventar si no hay ni workflow embebido ni meta utilizable. | impura |
|
||||
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `comfyui_harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', ...) -> dict` | One-shot Civitai → skill candidata: `search` → `fetch` (segrega NSFW) → `extract_recipe` → `save_skill`. Itera los items hasta hallar uno con receta destilable (preferentemente workflow embebido), descartando los PNG sin receta; 2º pase al feed global si filtró por modelo. **No baja modelos a ciegas**: los ausentes van a `missing_models`. | pipeline (impura) |
|
||||
|
||||
`build_skill_workflow` compone los builders del grupo [`comfyui`](comfyui.md):
|
||||
`comfyui_build_txt2img_workflow`, `comfyui_build_flux_workflow`,
|
||||
`comfyui_build_sdxl_refiner_workflow`, `comfyui_inject_lora`,
|
||||
`comfyui_build_facedetailer_workflow` y `comfyui_inject_hires_fix`.
|
||||
|
||||
## Ejemplo canónico end-to-end (receta → workflow → PNG → score)
|
||||
|
||||
Guardar una skill, cargarla, compilarla a un workflow para un sujeto, encolarla y puntuar el
|
||||
resultado con visión. Requiere el server ComfyUI en `127.0.0.1:8188` y los modelos de la receta
|
||||
instalados.
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_save_skill import comfyui_save_skill
|
||||
from ml.comfyui_load_skill import comfyui_load_skill
|
||||
from ml.comfyui_build_skill_workflow import build_skill_workflow
|
||||
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||
from ml.comfyui_wait_result import comfyui_wait_result
|
||||
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||
from core.ask_llm_vision import ask_llm_vision
|
||||
|
||||
# 1. Definir y guardar la skill (una vez).
|
||||
recipe = {
|
||||
"schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0",
|
||||
"title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img",
|
||||
"checkpoint": "dreamshaper_8.safetensors",
|
||||
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||
"params": {"steps": 28, "cfg": 6.0, "sampler_name": "dpmpp_2m", "scheduler": "karras"},
|
||||
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||
"negative": "blurry, lowres", "trigger_words": []},
|
||||
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
|
||||
"score_mean": 0.0, "score_n": 0, "provenance": {"source": "manual", "nsfw": False},
|
||||
}
|
||||
comfyui_save_skill(recipe) # ~/ComfyUI/skills_library/portrait_cinematic_sdxl/
|
||||
|
||||
# 2. Cargar + compilar a un workflow para un sujeto concreto.
|
||||
recipe = comfyui_load_skill("portrait_cinematic_sdxl")["recipe"]
|
||||
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
|
||||
|
||||
# 3. Encolar y esperar el PNG (camino headless del grupo comfyui).
|
||||
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||
outputs = comfyui_wait_result(pid)["outputs"]
|
||||
img = comfyui_fetch_output_image(outputs[0]["filename"], dest_dir="/tmp")["path"]
|
||||
|
||||
# 4. Puntuar el resultado con visión (alimenta el bucle de scoring de la skill).
|
||||
verdict = ask_llm_vision(
|
||||
"Puntúa de 0 a 10 el realismo de este retrato. Responde solo el número.",
|
||||
image_path=img, model="claude-opus-4-8",
|
||||
)
|
||||
print(verdict["text"])
|
||||
```
|
||||
|
||||
El paso "guardar la receta" se hace una sola vez; a partir de ahí cada generación es
|
||||
`load → build → submit`, cambiando solo el `subject` y la `seed`.
|
||||
|
||||
## Bucle de mejora (skill → genera → juzga → bump)
|
||||
|
||||
La doctrina del issue 0087 cerrada en un lazo: una skill **no crece inflando la receta a ciegas,
|
||||
crece registrando mejoras medibles**. El juez (no el humano) decide qué se promueve.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ generate_with_skill_oneshot(slug, subject, judge=True) │
|
||||
│ load → build → submit → wait → fetch → judge → score_mean │ ← canónica
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ score_before = score de la receta vigente
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ generate_with_skill_oneshot(..., recipe_patch={params:{...}}) │ ← variante (no guarda score)
|
||||
│ misma seed, un cambio plausible → judge.score = score_after │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ GATE objetivo
|
||||
comfyui_bump_skill_version(slug, change, score_before, score_after, judge_run_id=...)
|
||||
│
|
||||
score_after > score_before ?
|
||||
├── sí → promueve: versions/vN.json (snapshot) + recipe_patch + semver↑ + growth_log
|
||||
└── no → {ok:False} — NO se promueve (la variante se descarta)
|
||||
```
|
||||
|
||||
Pasos concretos:
|
||||
|
||||
1. **Genera la canónica** con `judge=True`. El panel `comfyui-judge` emite un `score` y el pipeline
|
||||
lo acumula en `score_mean`/`score_n` de la skill (vía `comfyui_update_skill_score`). Ese score es
|
||||
el `score_before`.
|
||||
2. **Genera una variante** con `recipe_patch` (p.ej. `{"params": {"steps": 32}}`) y la **misma seed**.
|
||||
El patch se aplica en memoria, NO se guarda, y su score NO contamina la media. Su `judge.score` es
|
||||
el `score_after`, y su `judge_run_id` es la evidencia.
|
||||
3. **Promueve con el gate**: `comfyui_bump_skill_version` aplica el patch a `recipe.json`, sube el
|
||||
semver y deja una línea en `growth_log.jsonl` **solo si `score_after > score_before`**. Si no
|
||||
mejora, devuelve `{ok:False}` y la receta se queda como estaba. El gate es objetivo: lo decide el
|
||||
número del juez, no quien lanza la generación.
|
||||
|
||||
Así `versions/` y `growth_log` reflejan **versiones de receta con mejora demostrada**, mientras
|
||||
`score_mean` es la telemetría de calidad media de la versión vigente.
|
||||
|
||||
## Skills como grafos en el navegador
|
||||
|
||||
Una skill no vive solo como receta JSON: se exporta a un **grafo de ComfyUI cargable como tal en el
|
||||
navegador**. `comfyui_export_skill_template` cierra ese hueco (receta → grafo):
|
||||
|
||||
```python
|
||||
from ml.comfyui_export_skill_template import export_skill_template
|
||||
|
||||
# Headless (sin navegador): congela el template API junto a la skill.
|
||||
export_skill_template("portrait_cinematic_sd15")
|
||||
# -> exports/portrait_cinematic_sd15.template.json (API format, node-template reproducible)
|
||||
|
||||
# Con navegador (pestaña ComfyUI abierta en CDP 9222): además el grafo visual posicionado.
|
||||
out = export_skill_template("portrait_cinematic_sd15", ui_graph=True, port=9222)
|
||||
# -> ~/ComfyUI/user/default/workflows/portrait_cinematic_sd15.json (aparece en el menú Workflows)
|
||||
```
|
||||
|
||||
Dos formatos, dos usos:
|
||||
|
||||
- **API format** (`exports/<slug>.template.json`) — el dict `{node_id:{class_type,inputs}}`. Se
|
||||
carga con `comfyui_load_workflow_ui` (`app.loadApiJson`, litegraph lo auto-posiciona) o va directo
|
||||
a `comfyui_submit_workflow`. Es el node-template versionable de la skill.
|
||||
- **UI graph** (`~/ComfyUI/user/default/workflows/<slug>.json` + copia en `exports/<slug>.ui.json`)
|
||||
— `nodes`/`links`/`pos` (`app.graph.serialize()`). La carpeta nativa de la UI **solo** acepta este
|
||||
formato; por eso solo se escribe con `ui_graph=True` (se genera vía CDP cargando el API en la UI y
|
||||
serializando el grafo posicionado). Es el que se abre como grafo visual desde el menú Workflows.
|
||||
|
||||
**Fotos ↔ grafo.** Cada PNG de ComfyUI lleva su workflow embebido (chunk `prompt`, API format).
|
||||
`comfyui_import_workflow_png` lo recupera, de modo que toda muestra de una skill queda asociada a su
|
||||
grafo reproducible 1:1 (ver `INDEX.md` de la librería: `samples/<base>.png` + `samples/<base>.graph.json`).
|
||||
|
||||
**No destructivo en el navegador**: `ui_graph=True` reemplaza el grafo in-memory de la pestaña. Si
|
||||
hay trabajo sin guardar (título con `*`), respalda antes con
|
||||
`comfyui_export_workflow_ui(api_format=True, save_path=...)` y restáuralo después con
|
||||
`comfyui_load_workflow_ui`.
|
||||
|
||||
## Cosecha Civitai → skill candidata
|
||||
|
||||
El registry crece también captando recetas que **ya existen en internet**, no solo escribiéndolas a
|
||||
mano: doctrina del issue 0087 aplicada a la captación de assets. Cada imagen publicada en Civitai
|
||||
suele llevar su workflow de ComfyUI embebido en el PNG (chunk `prompt`, API format); cosecharla
|
||||
destila la receta entera (checkpoint + LoRAs + params + prompt) en una **skill candidata** lista para
|
||||
juzgar.
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions", "pipelines"))
|
||||
from comfyui_harvest_civitai_skill_oneshot import comfyui_harvest_civitai_skill_oneshot
|
||||
|
||||
res = comfyui_harvest_civitai_skill_oneshot(
|
||||
query="cinematic portrait", nsfw="None",
|
||||
dest_dir=os.path.expanduser("~/ComfyUI/civitai_harvest"),
|
||||
)
|
||||
print(res["ok"], res["slug"], "workflow_embebido=", res["has_workflow"])
|
||||
print("guardada en:", res["skill_path"]) # ~/ComfyUI/skills_library/<slug>/recipe.json
|
||||
print("modelos ausentes:", res["missing_models"]) # checkpoints/LoRAs a bajar (NO bajados)
|
||||
```
|
||||
|
||||
Dos piezas, una composición:
|
||||
|
||||
- **`comfyui_extract_recipe_from_png`** (paso puro de destilación, impura solo por leer disco) —
|
||||
toma un PNG ya descargado y produce la receta candidata. Compone
|
||||
`comfyui_import_workflow_png` (workflow embebido) + `comfyui_read_png_metadata` (params del
|
||||
KSampler), con fallback a la `meta` de generación de Civitai y, para samplers no-KSampler
|
||||
(flux/`SamplerCustomAdvanced`), heurística sobre los nodos `CLIPTextEncode`. **Degradación honesta**:
|
||||
si no hay ni workflow embebido ni meta utilizable devuelve `ok=False` sin inventar la receta.
|
||||
- **`comfyui_harvest_civitai_skill_oneshot`** (pipeline) — encadena
|
||||
`search_civitai_images → fetch_civitai_image → extract_recipe_from_png → save_skill` en una sola
|
||||
llamada. Itera los resultados del search hasta encontrar el primero con receta destilable
|
||||
(descartando los PNG sin workflow), y si filtró por un modelo concreto y ninguno trae grafo, hace
|
||||
un 2º pase al feed global "Most Reactions" (donde abundan los workflows ComfyUI de usuarios flux).
|
||||
|
||||
Notas de uso:
|
||||
|
||||
- **La skill nace CANDIDATA** (`score_n=0`, `provenance.source='civitai'`): no está validada. El
|
||||
prompt cosechado es **concreto**, no un scaffold con `{subject}` — sustitúyelo a mano si quieres
|
||||
reutilizar la skill para otros sujetos. La validación la da el bucle
|
||||
`generate_with_skill_oneshot` (juzga) + `comfyui_bump_skill_version` (promueve si mejora).
|
||||
- **No baja modelos a ciegas**: si la receta referencia un checkpoint o LoRA que no está en
|
||||
`<comfyui_dir>/models/`, lo lista en `missing_models` y no descarga nada. Bajarlos
|
||||
(`comfyui_search_civitai_models` + `comfyui_download_model`) es una decisión aparte del caller.
|
||||
- **NSFW segregado**: el PNG se descarga a `<dest_dir>/nsfw/` si el item es NSFW (permitido pero
|
||||
siempre separado). El `dest_dir` vive fuera del repo (`~/ComfyUI/`) y se trata como datos: no se
|
||||
commitea ni se indexa.
|
||||
- El token Civitai es secreto: viene de `pass civitai/api-token`, nunca hardcodeado.
|
||||
|
||||
## Mezclar capacidades (mixer)
|
||||
|
||||
Una skill fija *una* receta. El **mixer** resuelve el otro eje: combinar **a la carta** todas las
|
||||
capacidades de generación sobre un mismo workflow base y activar/desactivar cada una para iterar.
|
||||
Misma doctrina del issue 0087 (componer piezas probadas, no reescribir el grafo), pero aplicada a
|
||||
mezclar capacidades en vez de a guardar una receta.
|
||||
|
||||
Dos funciones:
|
||||
|
||||
| ID | firma corta | qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_compose_capabilities_py_ml` | `compose_capabilities(base, *, loras, controlnet, ipadapter, hires, facedetailer) -> dict` | **PURA.** Aplica EN ORDEN las capacidades activadas (cada arg `None` = desactivada) sobre un dict base, componiendo los inyectores/builders encadenables. Reconecta MODEL/CLIP/positive/IMAGE. Sin ninguna = base intacto. |
|
||||
| `comfyui_generate_mixed_oneshot_py_pipelines` | `generate_mixed_oneshot(base, subject, *, capabilities, server, judge, ...) -> dict` | **Pipeline.** base (skill slug / `'txt2img'` / dict) → compose → submit → wait → fetch → (si `judge`) juzga. Devuelve `{ok, prompt_id, image_path, capabilities_active, judge, error}`. |
|
||||
|
||||
El mixer se apoya en los **inyectores encadenables-sobre-dict** (cada uno la versión componible de
|
||||
su builder-desde-cero hermano):
|
||||
|
||||
| Capacidad | Inyector | Reconecta |
|
||||
|---|---|---|
|
||||
| LoRAs (N) | `comfyui_inject_multi_lora_py_ml` | cadena MODEL/CLIP tras el checkpoint |
|
||||
| ControlNet | `comfyui_inject_controlnet_py_ml` | `KSampler.positive` ← `ControlNetApply` |
|
||||
| IPAdapter (style/faceid) | `comfyui_inject_ipadapter_py_ml` | `KSampler.model` ← IPAdapter (tras las LoRAs) |
|
||||
| hires/upscale | `comfyui_inject_hires_fix_py_ml` | `UltimateSDUpscale` tras el `VAEDecode` |
|
||||
| FaceDetailer | `comfyui_build_facedetailer_workflow_py_ml` | regenera caras del `VAEDecode` |
|
||||
|
||||
Orden fijo: `loras → controlnet → ipadapter → facedetailer → hires`. El IPAdapter se aplica sobre
|
||||
el MODEL ya modificado por los LoRAs (orden correcto). Tras FaceDetailer el mixer deja un único
|
||||
`SaveImage` (el del detailer).
|
||||
|
||||
### Ejemplo canónico (≥3 capacidades, juzgado)
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from pipelines.comfyui_generate_mixed_oneshot import comfyui_generate_mixed_oneshot
|
||||
|
||||
# txt2img dreamshaper + 2 LoRAs + FaceDetailer (3 capacidades). Activar/desactivar = cambiar args.
|
||||
res = comfyui_generate_mixed_oneshot(
|
||||
"txt2img",
|
||||
"a heroic knight portrait, 3d render style, dramatic lighting, detailed face",
|
||||
checkpoint="dreamshaper_8.safetensors",
|
||||
capabilities={
|
||||
"loras": [
|
||||
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
|
||||
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
|
||||
],
|
||||
"facedetailer": {"denoise": 0.45},
|
||||
# "ipadapter": {"ref_image": "face.png", "mode": "faceid"}, # se activa con solo añadirla
|
||||
# "hires": {"upscale_by": 1.5},
|
||||
},
|
||||
dest="/tmp/comfy_mixed", seed=42, judge=True,
|
||||
)
|
||||
print(res["ok"], res["prompt_id"], res["capabilities_active"], res["judge"])
|
||||
```
|
||||
|
||||
### Límite conocido (8GB / piezas actuales)
|
||||
|
||||
- **hires + facedetailer no encadenan**: ambos toman su imagen del `VAEDecode` del render base, así
|
||||
que combinarlos deja a uno sin efecto sobre la salida final (con los dos activos, hires "gana" y
|
||||
facedetailer queda sin consumidor). Usa uno U otro por workflow. El resto de combinaciones
|
||||
(LoRAs + ControlNet + IPAdapter + uno de los dos post-procesos) encadenan limpio.
|
||||
- **VRAM**: en 8GB lowvram con SD1.5 entran ~2-3 capacidades modestas (p.ej. 2 LoRAs + FaceDetailer
|
||||
a 512px). Apilar IPAdapter FaceID + ControlNet + hires + facedetailer a la vez puede dar OOM —
|
||||
baja resolución o reduce capacidades. `mixer` no valida VRAM; el OOM aflora en `wait`.
|
||||
- **Incompatibilidad explícita, no silenciosa**: ControlNet sin `control_image` o IPAdapter sin
|
||||
`ref_image` lanzan `ValueError` del inyector (no petan a medias). Las imágenes de control/referencia
|
||||
deben estar en el `input/` del servidor antes de encolar.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben
|
||||
estar ya instalados en ComfyUI (`comfyui_download_model`, otro flujo). `build_skill_workflow` es
|
||||
puro y no valida contra el servidor — usa `comfyui_validate_workflow` antes de encolar si dudas.
|
||||
- **`base_workflow` solo de texto**: `txt2img`, `flux`, `sdxl_refiner`. Las bases que parten de una
|
||||
imagen (`img2img`, `inpaint`, `controlnet`) lanzan `SkillWorkflowError`; para esas, monta el
|
||||
workflow con los builders del grupo `comfyui` directamente.
|
||||
- **`blocks` soportados**: `facedetailer` y `hires_fix`. Otros post-procesos (IPAdapter,
|
||||
multi-ControlNet) se añaden creando su función-inyector hermana y registrándola en el dispatcher
|
||||
de `build_skill_workflow`.
|
||||
- **El juicio (`comfyui-judge`) vive en su grupo**: este grupo lo *consume* (vía
|
||||
`generate_with_skill_oneshot` con `judge=True`), pero el panel multi-juez —estético + CLIP +
|
||||
LLM-vision— se documenta en [`comfyui-judge`](comfyui-judge.md). Aquí solo se acumula su `score`
|
||||
en `score_mean` (`comfyui_update_skill_score`) y se usa como gate del bump.
|
||||
- **El bump solo sube versiones, no genera ni juzga**: `comfyui_bump_skill_version` aplica el patch
|
||||
y registra la mejora; generar la imagen y puntuarla es trabajo del pipeline + el panel-juez. Una
|
||||
variante que no supera a la vigente se descarta sola (el gate la rechaza).
|
||||
- **La librería es metadata local**: vive bajo `~/ComfyUI/skills_library` (no toca el venv ni los
|
||||
modelos en disco). No tiene repo propio ni se indexa — es estado vivo, como un `operations.db`.
|
||||
- **Las funciones impuras del grupo** (save/load/list, ask_llm_vision) no llevan unit tests por
|
||||
diseño (I/O de disco / red); `build_skill_workflow` e `inject_hires_fix` son puras y sí tienen
|
||||
tests de estructura offline (`python/functions/ml/tests/test_comfyui_build_skill_workflow.py`,
|
||||
`test_comfyui_inject_hires_fix.py`).
|
||||
@@ -0,0 +1,292 @@
|
||||
# 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
|
||||
|
||||
### Lifecycle del server — dominio `infra`
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_ensure_server_py_infra](../../python/functions/infra/comfyui_ensure_server.md) | `ensure_server(*, port=8188, lowvram=None, health_timeout=60, comfyui_dir='~/ComfyUI', unit_name='comfyui', runner=None) -> dict` | Garantiza que ComfyUI corre como servicio **systemd-user resiliente y sano**: genera/instala el unit (`Restart=always`, `--lowvram` autodetectado en GPUs ≤ 8 GB), daemon-reload + enable + start, y verifica salud por GET `/system_stats`. Idempotente; migra limpio un ComfyUI lanzado a mano (SIGTERM, nunca SIGKILL). Solo stdlib, no lanza excepciones → dict de estado. Prerequisito de todas las funciones HTTP. Impura. |
|
||||
|
||||
### 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_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **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. |
|
||||
| [comfyui_fetch_output_video_py_ml](../../python/functions/ml/comfyui_fetch_output_video.md) | `fetch_output_video(prompt_id, *, server, dest=None, outputs=None, timeout) -> dict` | Localiza y descarga el output de **vídeo/animación** (`.mp4`/`.webp`/`.webm`/`.gif`) de `/history` vía GET `/view`. Cubre SaveAnimatedWEBP/SaveVideo (bajo `"images"`) y VHS_VideoCombine (bajo `"gifs"`). Hermana de `fetch_output_image`/`fetch_output_mesh`. Acepta `outputs=` de `wait_result` para evitar re-consultar `/history`. 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_run_foreign_workflow_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_run_foreign_workflow_oneshot.md) | `run_foreign_workflow_oneshot(source, *, server, dest=None, output_kind='auto', install_nodes=False, node_repos=None, wait_timeout, civitai_token, hf_token) -> dict` | **Pipeline** para ejecutar un workflow ComfyUI **ajeno** end-to-end en una llamada: import (cualquier fuente) → resolve deps → (instala solo nodos confiables opt-in) → validate → submit → wait → fetch (imagen/vídeo/malla). **Gate de seguridad**: si faltan deps NO encola y las reporta en `missing`; nunca descarga modelos a ciegas. Compone `download_workflow` + `resolve_workflow_deps` + `install_custom_node` + `submit`/`wait` + `fetch_output_image/video/mesh`. Promoción del roadmap 0064/0087. Impuro. |
|
||||
| [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. |
|
||||
|
||||
### Cosecha de Civitai → skills candidatas — dominio `ml` + `pipelines` (issue 0087)
|
||||
|
||||
Cosechar de Civitai imágenes con su workflow+receta embebidos para clonar su calidad y alimentar
|
||||
la librería de skills (grupo [`comfyui-skill`](comfyui-skill.md)). En vez de reconstruir a mano una
|
||||
receta que ya existe en una imagen pública, se cosecha y se guarda como **candidata** (`score_n=0`,
|
||||
`provenance.source='civitai'`) para que el bucle de juicio/bump la valide. Política: **NSFW
|
||||
permitido pero SIEMPRE segregado** en carpeta marcada. **Gotcha clave**: la API de Civitai ya no
|
||||
expone `meta` (viene `null`) — la receta real sale del **workflow ComfyUI embebido en el PNG**, no
|
||||
de la meta inline.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_search_civitai_images_py_ml](../../python/functions/ml/comfyui_search_civitai_images.md) | `search_civitai_images(*, query=None, model_version_id=None, nsfw='None', sort='Most Reactions', limit=20, token=None) -> dict` | Busca imágenes en Civitai (GET /api/v1/images) → items con `url` (PNG con workflow embebido). El endpoint no admite query textual (HTTP 500): resuelve `query`→versión de modelo via `search_civitai_models`. Token de `pass civitai/api-token`. Reintenta 503. Impura. |
|
||||
| [comfyui_fetch_civitai_image_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image.md) | `fetch_civitai_image(image_url, *, dest_dir, nsfw=False, nsfw_subdir='nsfw', token=None, prefer_original=True, timeout_s=120) -> dict` | Descarga el PNG original (reescribe `/width=N/`→`/original=true/` para conservar el workflow), **segregando NSFW** a `<dest_dir>/nsfw/`. Misma validación no-HTML que `download_model`; nombra por UUID. Impura. |
|
||||
| [comfyui_extract_recipe_from_png_py_ml](../../python/functions/ml/comfyui_extract_recipe_from_png.md) | `extract_recipe_from_png(png_path, *, slug=None, civitai_meta=None, image_url='', nsfw=False) -> dict` | Destila un PNG cosechado en receta de skill candidata (schema `comfyui-skill`, `source='civitai'`, `score_n=0`). Compone `import_workflow_png` + `read_png_metadata` + fallback de prompts/ckpt para flux. Sin workflow → usa `civitai_meta` (degradación honesta). Impura. |
|
||||
| [comfyui_harvest_civitai_skill_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_harvest_civitai_skill_oneshot.md) | `harvest_civitai_skill_oneshot(*, query=None, model_version_id=None, nsfw='None', dest_dir, library_dir='~/ComfyUI/skills_library', comfyui_dir='~/ComfyUI', token=None, ...) -> dict` | **Pipeline** Civitai→skill candidata: search → fetch (segrega NSFW) → extract → save_skill. Itera items hasta uno con receta destilable (2º pase al feed global si filtró por modelo). **NO baja modelos a ciegas**: checkpoint/LoRA ausente → `missing_models`. Impuro. |
|
||||
|
||||
### Replicación desde un link de Civitai — dominio `ml` + `pipelines` (issue C5, report 0127)
|
||||
|
||||
"Te paso un link de Civitai: entra, observa cómo lo hicieron, y construye un workflow que lo
|
||||
replique." Dado el id/URL de una imagen de Civitai → extrae la receta (prompt, modelo, sampler,
|
||||
LoRAs) → reconstruye el workflow → lo genera y lo juzga. **Gotcha clave**: la API v1 `/images`
|
||||
devuelve `meta=null`; la receta por id sale de los endpoints **tRPC** `image.getGenerationData` +
|
||||
`image.get` (los que usa la web). Como casi nunca tendrás el checkpoint/LoRA exacto, se sustituye
|
||||
por el más parecido **instalado** (misma familia) y lo ausente se reporta en `missing_models` (NUNCA
|
||||
se descarga a ciegas). El parecido es aproximado cuando falta el modelo exacto — esperado. SFW
|
||||
estricto: una imagen NSFW devuelve `ok=False` sin generar.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_fetch_civitai_image_meta_py_ml](../../python/functions/ml/comfyui_fetch_civitai_image_meta.md) | `fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0) -> dict` | "Entra al link y observa": resuelve UNA imagen Civitai por id/URL vía tRPC `image.getGenerationData` + `image.get` → `{meta, resources, comfy_workflow, nsfw, ...}`. Donde `search_civitai_images` da `meta=null`, esta sí trae prompt/modelo/sampler. Impura. |
|
||||
| [comfyui_map_a1111_params_py_ml](../../python/functions/ml/comfyui_map_a1111_params.md) | `map_a1111_params(meta, resources=None) -> dict` | **Pura**: traduce meta A1111/Civitai a params ComfyUI (sampler `DPM++ 2M Karras`→`dpmpp_2m`/`karras`, dims, seed), infiere familia (`sd15`/`sdxl`/`flux`) y extrae LoRAs (de resources y tags `<lora:..>` del prompt). |
|
||||
| [comfyui_replicate_civitai_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_replicate_civitai_oneshot.md) | `replicate_civitai_oneshot(url_or_id, *, server, dest=None, judge=True, token=None, wait_timeout=600) -> dict` | **Pipeline** link Civitai→réplica: fetch_meta → map_a1111_params → workflow embebido tal cual O reconstruido (build_txt2img + inject_lora, **sustituye checkpoint ausente por el más parecido instalado**, omite LoRAs ausentes) → run_foreign_workflow_oneshot → judge_image. Acepta también `modelVersionId` o un workflow ajeno (PNG/.json/dict). Impuro. |
|
||||
|
||||
### 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**. |
|
||||
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **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`: `zero123` azimuth o
|
||||
`sv3d` orbit de 21 frames, ambos operativos en 8 GB — reports `0073`, `0128`); 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, video_frames=21, sv3d_width=576, sv3d_height=576, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Ambos caminos operativos**: `method='zero123'` (azimuth → back/left/right) y `method='sv3d'` (`sv3d_p.safetensors`, orbit de N frames 360° → `frames` + cardinales mapeados; probado en 8 GB lowvram, 21f@576 ~75 s, peak ~5.7 GB, report 0128). **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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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,67 @@
|
||||
---
|
||||
name: comfyui_clear_node_outputs_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_clear_node_outputs_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||
description: "Limpia outputs/previews residuales de TODOS los nodos del grafo de ComfyUI en la UI via CDP: vacia app.nodeOutputs (store de previews keyed by node_id) y borra imgs/images de cada nodo vivo, sin tocar la topologia del grafo (no borra nodos ni links). Arregla el bug de imagenes pegadas a nodos que no corresponden tras cargar un workflow nuevo con app.loadApiJson. Compone cdp_eval. Impura: red (CDP) + muta la UI."
|
||||
tags: [comfyui, browser, cdp, ml, ui-automation, image-generation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
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', el puerto del server). Identifica la pestana entre las abiertas."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||
output: "dict {ok, cleared, error, store_cleared, nodes_touched, nodes}. ok/cleared True si la limpieza termino sin excepcion. store_cleared = entradas borradas de app.nodeOutputs; nodes_touched = nodos a los que se les quito un preview; nodes = total de nodos del grafo."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_clear_node_outputs_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||
|
||||
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
|
||||
print(comfyui_clear_node_outputs_ui())
|
||||
# -> {'ok': True, 'cleared': True, 'error': '', 'store_cleared': 6, 'nodes_touched': 2, 'nodes': 12}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ves previews/outputs de imagenes pegados a nodos que no los produjeron
|
||||
(una imagen bajo un `CheckpointLoaderSimple`, un `SaveGLB`, etc.) tras haber
|
||||
cargado varios workflows seguidos en la misma pestana. Es la limpieza no
|
||||
destructiva: borra los previews residuales del grafo actual SIN recargarlo ni
|
||||
perder la topologia. `comfyui_load_workflow_ui(..., clear_outputs=True)` la
|
||||
invoca automaticamente antes de cargar, asi que normalmente no hace falta
|
||||
llamarla a mano; usala solo para limpiar un grafo ya cargado sin recargarlo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere la pestana de ComfyUI 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`.
|
||||
- Borra TODOS los previews del grafo, incluidos los legitimos de la ultima
|
||||
ejecucion. Si quieres conservar un preview concreto, no la llames; el residuo
|
||||
cross-workflow se evita de raiz cargando con
|
||||
`comfyui_load_workflow_ui(..., clear_outputs=True)`.
|
||||
- No es `app.clean()`: a proposito NO hace `rootGraph.clear()`, por eso es
|
||||
segura sobre el grafo vivo del usuario (no borra nodos ni conexiones).
|
||||
- El store que vacia es `app.nodeOutputs`; el nombre interno puede variar entre
|
||||
versiones de ComfyUI. Si una version renombra el store, el borrado del store
|
||||
no aplica pero el barrido de `node.imgs`/`node.images` sigue limpiando los
|
||||
previews visibles.
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Limpia los outputs/previews residuales de los nodos de ComfyUI en la UI via CDP.
|
||||
|
||||
ComfyUI cachea los outputs de cada ejecucion en `app.nodeOutputs`, un store
|
||||
indexado por node_id. La ruta de carga `app.loadApiJson` (la que usa
|
||||
comfyui_load_workflow_ui) reconstruye el grafo pero NO resetea ese store ni los
|
||||
previews de los nodos. Cuando un workflow nuevo reusa un node_id que ya existia
|
||||
en el store, el preview cacheado del workflow anterior se vuelve a pintar sobre
|
||||
el nodo nuevo, que muchas veces es de otro tipo (ej. una imagen pegada bajo un
|
||||
`CheckpointLoaderSimple` o un `SaveGLB`).
|
||||
|
||||
Esta funcion vacia `app.nodeOutputs` y borra `imgs`/`images` de todos los nodos
|
||||
vivos del grafo, sin tocar la topologia del grafo (no borra nodos ni links), y
|
||||
marca el canvas dirty para repintar. Es la version no destructiva de
|
||||
`app.clean()` (que ademas haria `rootGraph.clear()`).
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y muta el estado de la UI.
|
||||
"""
|
||||
|
||||
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_clear_node_outputs_ui(
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Limpia previews/outputs residuales de todos los nodos del grafo 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 (default
|
||||
"8188", el puerto del server). Identifica la pestana entre las
|
||||
abiertas.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, cleared: bool, error: str, store_cleared: int,
|
||||
nodes_touched: int, nodes: int}. ok/cleared True si la limpieza termino
|
||||
sin excepcion en la pagina. `store_cleared` es el numero de entradas
|
||||
eliminadas de `app.nodeOutputs`; `nodes_touched` los nodos a los que se
|
||||
les quito un preview; `nodes` el total de nodos del grafo.
|
||||
"""
|
||||
expr = (
|
||||
"(function(){"
|
||||
" if(!window.app){ return {ok:false, cleared:false, error:'window.app no disponible en la pestana'}; }"
|
||||
" try{"
|
||||
" var store=0;"
|
||||
" if(app.nodeOutputs){ for(var k in app.nodeOutputs){ if(Object.prototype.hasOwnProperty.call(app.nodeOutputs,k)){ delete app.nodeOutputs[k]; store++; } } }"
|
||||
" var nodes=(app.graph && app.graph._nodes)? app.graph._nodes : [];"
|
||||
" var touched=0;"
|
||||
" for(var i=0;i<nodes.length;i++){"
|
||||
" var nd=nodes[i];"
|
||||
" if(nd.imgs!==undefined || nd.images!==undefined){ touched++; }"
|
||||
" nd.imgs=undefined;"
|
||||
" nd.images=undefined;"
|
||||
" nd.imageIndex=null;"
|
||||
" nd.overIndex=null;"
|
||||
" if('animatedImages' in nd){ nd.animatedImages=undefined; }"
|
||||
" }"
|
||||
" if(app.graph && app.graph.setDirtyCanvas){ app.graph.setDirtyCanvas(true,true); }"
|
||||
" return {ok:true, cleared:true, error:'', store_cleared:store, nodes_touched:touched, nodes:nodes.length};"
|
||||
" }catch(e){ return {ok:false, cleared:false, error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
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,
|
||||
"cleared": False,
|
||||
"error": r["error"],
|
||||
"store_cleared": 0,
|
||||
"nodes_touched": 0,
|
||||
"nodes": 0,
|
||||
}
|
||||
val = r["value"] or {}
|
||||
return {
|
||||
"ok": bool(val.get("cleared")),
|
||||
"cleared": bool(val.get("cleared")),
|
||||
"error": val.get("error", ""),
|
||||
"store_cleared": int(val.get("store_cleared", 0)),
|
||||
"nodes_touched": int(val.get("nodes_touched", 0)),
|
||||
"nodes": int(val.get("nodes", 0)),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
comfyui_clear_node_outputs_ui(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
@@ -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,84 @@
|
||||
---
|
||||
name: comfyui_load_workflow_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_load_workflow_ui(workflow: dict, *, port: int = 9222, server_url_substr: str = '8188', filename: str = 'workflow.json', clear_outputs: bool = True, 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. Por defecto (clear_outputs=True) limpia antes los previews/outputs residuales para que un preview cacheado del workflow anterior no se pegue a un nodo nuevo que reusa el mismo node_id. Compone cdp_eval + comfyui_clear_node_outputs_ui. 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", "comfyui_clear_node_outputs_ui_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: clear_outputs
|
||||
desc: "Si True (default) limpia previews/outputs residuales (app.nodeOutputs + node.imgs) antes de cargar, evitando que un preview cacheado de un workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id. False conserva los previews previos a proposito."
|
||||
- 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")`.
|
||||
- `app.loadApiJson` (a diferencia de la ruta del menu `app.loadGraphData`) NO
|
||||
llama a `app.clean()`, asi que NO resetea el store `app.nodeOutputs` ni los
|
||||
previews de los nodos. Sin `clear_outputs=True`, un preview cacheado de un
|
||||
workflow anterior se re-pinta sobre el nodo nuevo que reuse el mismo node_id
|
||||
(visto: imagen 3D pegada bajo un `CheckpointLoaderSimple`/`SaveGLB`). El
|
||||
default `clear_outputs=True` lo evita delegando en
|
||||
`comfyui_clear_node_outputs_ui`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-24) — anade `clear_outputs=True` (default): limpia los
|
||||
previews/outputs residuales (`app.nodeOutputs` + `node.imgs`) antes de cargar,
|
||||
delegando en `comfyui_clear_node_outputs_ui`. Fija el bug de imagenes
|
||||
residuales pegadas a nodos que reusan node_id entre workflows.
|
||||
@@ -0,0 +1,96 @@
|
||||
"""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
|
||||
from comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
from browser.comfyui_clear_node_outputs_ui import comfyui_clear_node_outputs_ui
|
||||
|
||||
|
||||
def comfyui_load_workflow_ui(
|
||||
workflow: dict,
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
filename: str = "workflow.json",
|
||||
clear_outputs: bool = True,
|
||||
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.
|
||||
clear_outputs: si True (default), limpia los previews/outputs residuales
|
||||
(app.nodeOutputs + node.imgs) ANTES de cargar, replicando lo que hace
|
||||
la ruta del menu (app.clean()). Evita que un preview cacheado de un
|
||||
workflow anterior se pegue a un nodo nuevo que reusa el mismo node_id
|
||||
(bug de imagenes residuales). Ponlo en False solo si quieres conservar
|
||||
a proposito los previews del grafo previo.
|
||||
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.
|
||||
"""
|
||||
if clear_outputs:
|
||||
comfyui_clear_node_outputs_ui(
|
||||
port=port,
|
||||
server_url_substr=server_url_substr,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: ask_llm_vision
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def ask_llm_vision(prompt: str, image_path: str = '', *, image_b64: str = '', media_type: str = '', model: str = 'claude-opus-4-8', system: str = '', max_tokens: int = 4096, echo: bool = False, token: str = '') -> dict"
|
||||
description: "Pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Construye un content block [imagen base64, texto] y devuelve dict {ok, text, model, error}. Wrapper sobre stream_anthropic_messages (grupo claude-direct, arranque 0, sin proceso claude). Util para describir/puntuar/clasificar imagenes (p.ej. evaluar una generacion ComfyUI)."
|
||||
error_type: error_go_core
|
||||
tags: ["claude-direct", "llm", "anthropic", "vision", "multimodal", "image", "oauth"]
|
||||
uses_functions:
|
||||
- stream_anthropic_messages_py_core
|
||||
uses_types: []
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "Pregunta o instruccion de texto sobre la imagen."
|
||||
- name: image_path
|
||||
desc: "Ruta a la imagen en disco; se lee y codifica a base64. El media_type se deduce de la extension si no se pasa. Ignorado si se pasa image_b64."
|
||||
- name: image_b64
|
||||
desc: "Alternativa a image_path: la imagen ya en base64 (sin prefijo data:). Requiere media_type explicito. keyword-only."
|
||||
- name: media_type
|
||||
desc: "Tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif). Obligatorio con image_b64; deducido del path si se omite. keyword-only."
|
||||
- name: model
|
||||
desc: "Id del modelo Anthropic con vision. Default claude-opus-4-8. keyword-only."
|
||||
- name: system
|
||||
desc: "System prompt opcional (string vacio = ninguno). keyword-only."
|
||||
- name: max_tokens
|
||||
desc: "Maximo de tokens de salida. Default 4096. keyword-only."
|
||||
- name: echo
|
||||
desc: "Si True, vuelca el texto a stdout segun llega (streaming). keyword-only."
|
||||
- name: token
|
||||
desc: "Token OAuth; si vacio lo carga stream_anthropic_messages automaticamente. keyword-only."
|
||||
output: "dict {ok, text, model, error}. En exito ok=True y text lleva la respuesta completa; en error ok=False, text vacio y error describe la causa. Nunca lanza excepcion."
|
||||
file_path: python/functions/core/ask_llm_vision.py
|
||||
---
|
||||
|
||||
# ask_llm_vision
|
||||
|
||||
Versión multimodal de [`ask_llm`](ask_llm.md): adjunta una imagen al prompt y devuelve la
|
||||
respuesta del modelo. Usa la API directa de Anthropic con el token OAuth de Claude Max (sin
|
||||
proceso `claude`, arranque 0), reutilizando [`stream_anthropic_messages`](stream_anthropic_messages.md),
|
||||
que ya acepta content blocks multimodales. Cumple la regla `llm_invocation`: SIEMPRE claude-direct,
|
||||
NUNCA `claude -p`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# CLI (fn run): describe una imagen
|
||||
fn run ask_llm_vision "describe esta imagen en una frase" --image ~/ComfyUI/output/demo_00001_.png
|
||||
|
||||
# Directo con el venv
|
||||
python/.venv/bin/python3 python/functions/core/ask_llm_vision.py \
|
||||
"que defectos ves en esta cara?" --image /tmp/render.png --model claude-opus-4-8
|
||||
```
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from core.ask_llm_vision import ask_llm_vision
|
||||
|
||||
res = ask_llm_vision(
|
||||
"Puntua de 0 a 10 el realismo de este retrato y justifica en una frase.",
|
||||
image_path="/tmp/portrait.png",
|
||||
model="claude-opus-4-8",
|
||||
)
|
||||
if res["ok"]:
|
||||
print(res["text"])
|
||||
else:
|
||||
print("error:", res["error"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites el juicio del modelo **sobre una imagen** (describir, puntuar, clasificar,
|
||||
comparar, detectar defectos). Caso típico: cerrar el bucle de scoring de una skill ComfyUI —
|
||||
generas un PNG y `ask_llm_vision` lo evalúa para alimentar `score_mean`.
|
||||
- Si solo mandas texto, usa `ask_llm`. Si necesitas tools / loop agéntico, `run_claude_tool_loop_py_core`.
|
||||
Si necesitas los eventos crudos (deltas, tool_use), `stream_anthropic_messages_py_core`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Es impura y hace red**: una request HTTP a `api.anthropic.com` por llamada. Sujeta a los
|
||||
rate limits del plan (`HTTP 429` en ráfagas → espacia o reporta el error del dict).
|
||||
- **media_type obligatorio con `image_b64`**: sin extensión de archivo no se puede deducir; pásalo
|
||||
explícito (`image/png`, `image/jpeg`, `image/webp`, `image/gif`).
|
||||
- **No lanza excepción**: errores (imagen inexistente, fallo HTTP) salen como `{ok: False, error: ...}`,
|
||||
no como `raise`. Comprueba siempre `res["ok"]` antes de usar `res["text"]`.
|
||||
- **Tamaño de la imagen**: imágenes muy grandes inflan los tokens de entrada (la API escala/limita
|
||||
internamente). Para puntuar en lote conviene reducir la resolución antes.
|
||||
- **Modelo con visión**: usa un modelo multimodal (`claude-opus-4-8` por defecto). Un id inexistente
|
||||
da `404 not_found_error` propagado en `error`.
|
||||
@@ -0,0 +1,167 @@
|
||||
"""ask_llm_vision — pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic.
|
||||
|
||||
Wrapper sobre `stream_anthropic_messages` (grupo claude-direct) que construye un mensaje
|
||||
multimodal `[bloque de imagen base64, bloque de texto]` y devuelve la respuesta del modelo.
|
||||
Respeta la regla `llm_invocation`: SIEMPRE API directa (claude-direct, arranque 0 con el token
|
||||
OAuth de Claude Max), NUNCA `claude -p`.
|
||||
|
||||
A diferencia de `ask_llm`, que solo manda texto, esta funcion adjunta una imagen — util para
|
||||
describir, puntuar o clasificar imagenes (p.ej. evaluar el resultado de una generacion ComfyUI).
|
||||
|
||||
Impura: lee la imagen de disco y hace una request HTTP a api.anthropic.com.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
|
||||
|
||||
DEFAULT_MODEL = "claude-opus-4-8"
|
||||
|
||||
_MEDIA_TYPES = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpe": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _media_type_for(path: str) -> str:
|
||||
"""Deduce el media_type MIME a partir de la extension del archivo (o "" si no se reconoce)."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return _MEDIA_TYPES.get(ext, "")
|
||||
|
||||
|
||||
def ask_llm_vision(
|
||||
prompt: str,
|
||||
image_path: str = "",
|
||||
*,
|
||||
image_b64: str = "",
|
||||
media_type: str = "",
|
||||
model: str = DEFAULT_MODEL,
|
||||
system: str = "",
|
||||
max_tokens: int = 4096,
|
||||
echo: bool = False,
|
||||
token: str = "",
|
||||
) -> dict:
|
||||
"""Pregunta al modelo sobre una imagen y devuelve su respuesta de texto.
|
||||
|
||||
Construye un mensaje multimodal con un bloque de imagen (base64) seguido del bloque de
|
||||
texto del prompt, y lo envia a la API Messages de Anthropic via
|
||||
`stream_anthropic_messages` (token OAuth de Claude Max, sin proceso `claude`).
|
||||
|
||||
Args:
|
||||
prompt: pregunta/instruccion de texto sobre la imagen.
|
||||
image_path: ruta a la imagen en disco. Se lee y codifica a base64; el media_type se
|
||||
deduce de la extension si no se pasa. Ignorado si se pasa image_b64.
|
||||
image_b64: alternativa a image_path: la imagen ya en base64 (sin prefijo `data:`).
|
||||
Requiere `media_type` explicito. keyword-only.
|
||||
media_type: tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif).
|
||||
Obligatorio con image_b64; deducido del path si se omite. keyword-only.
|
||||
model: id del modelo Anthropic con vision. Default "claude-opus-4-8". keyword-only.
|
||||
system: system prompt opcional. keyword-only.
|
||||
max_tokens: maximo de tokens de salida. keyword-only.
|
||||
echo: si True, vuelca el texto a stdout segun llega (streaming). keyword-only.
|
||||
token: token OAuth; si vacio, lo carga `stream_anthropic_messages` automaticamente.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict ``{ok, text, model, error}``. En exito ``ok=True`` y ``text`` lleva la respuesta
|
||||
completa del modelo. En error ``ok=False``, ``text=""`` y ``error`` describe la causa
|
||||
(imagen inexistente, falta media_type, error HTTP/API). Nunca lanza excepcion.
|
||||
"""
|
||||
if not image_path and not image_b64:
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": "falta la imagen: pasa image_path o image_b64"}
|
||||
|
||||
if image_b64:
|
||||
b64 = image_b64
|
||||
mt = media_type
|
||||
if not mt:
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": "image_b64 requiere media_type explicito (ej. image/png)"}
|
||||
else:
|
||||
path = os.path.expanduser(image_path)
|
||||
if not os.path.isfile(path):
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": f"imagen no encontrada: {path}"}
|
||||
mt = media_type or _media_type_for(path)
|
||||
if not mt:
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": f"no se pudo deducir media_type de {path!r}; pasa media_type explicito"}
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
b64 = base64.standard_b64encode(fh.read()).decode("ascii")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": f"no se pudo leer la imagen: {exc}"}
|
||||
|
||||
content = [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": mt, "data": b64}},
|
||||
{"type": "text", "text": prompt},
|
||||
]
|
||||
messages = [{"role": "user", "content": content}]
|
||||
|
||||
parts = []
|
||||
for ev in stream_anthropic_messages(
|
||||
messages=messages,
|
||||
model=model,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
token=token,
|
||||
):
|
||||
t = ev.get("type")
|
||||
if t == "text":
|
||||
parts.append(ev["text"])
|
||||
if echo:
|
||||
sys.stdout.write(ev["text"])
|
||||
sys.stdout.flush()
|
||||
elif t == "error":
|
||||
return {"ok": False, "text": "", "model": model,
|
||||
"error": ev.get("message", "error desconocido")}
|
||||
|
||||
if echo:
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
return {"ok": True, "text": "".join(parts), "model": model, "error": ""}
|
||||
|
||||
|
||||
def _main(argv):
|
||||
image_path = ""
|
||||
model = DEFAULT_MODEL
|
||||
system = ""
|
||||
prompt_parts = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in ("--image", "-i") and i + 1 < len(argv):
|
||||
image_path = argv[i + 1]
|
||||
i += 2
|
||||
elif a in ("--model", "-m") and i + 1 < len(argv):
|
||||
model = argv[i + 1]
|
||||
i += 2
|
||||
elif a in ("--system", "-s") and i + 1 < len(argv):
|
||||
system = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
prompt_parts.append(a)
|
||||
i += 1
|
||||
prompt = " ".join(prompt_parts).strip()
|
||||
if not image_path:
|
||||
sys.stderr.write('uso: ask_llm_vision "prompt" --image RUTA [--model M] [--system S]\n')
|
||||
return 2
|
||||
if not prompt:
|
||||
prompt = "Describe esta imagen."
|
||||
res = ask_llm_vision(prompt, image_path, model=model, system=system, echo=True)
|
||||
if not res["ok"]:
|
||||
sys.stderr.write("ask_llm_vision error: " + str(res["error"]) + "\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv[1:]))
|
||||
@@ -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)
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: comfyui_ensure_server
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_ensure_server(*, port: int = 8188, lowvram: bool | None = None, health_timeout: int = 60, comfyui_dir: str = '~/ComfyUI', unit_name: str = 'comfyui', runner=None) -> dict"
|
||||
description: "Garantiza que ComfyUI corre como servicio systemd-user resiliente y sano. Genera/instala el unit systemd-user comfyui.service (ExecStart con el venv de ComfyUI + main.py --port, anadiendo --lowvram si lowvram=True o autodetectando GPUs <= 8 GB; Restart=always — NO on-failure; WantedBy=default.target), hace daemon-reload + enable + start, y comprueba la salud via GET /system_stats (2xx) con timeout. Idempotente: si el servicio ya esta gestionado por systemd, activo y respondiendo, no toca nada. Migracion limpia: si ComfyUI ya corre a mano (puerto ocupado por un proceso main.py que systemd NO gestiona), lo para con SIGTERM (nunca SIGKILL) y lo levanta via systemd. Solo stdlib (subprocess, urllib, os, signal, time, re). No lanza excepciones: devuelve un dict de estado."
|
||||
tags: [comfyui, systemd, service, server, resilient, ml, healthcheck, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "re", "signal", "subprocess", "time", "urllib.request"]
|
||||
params:
|
||||
- name: port
|
||||
desc: "puerto HTTP del backend ComfyUI; tambien el que escribe en el unit (--port) y el que sondea el health check (default 8188)"
|
||||
- name: lowvram
|
||||
desc: "True/False fuerza/omite el flag --lowvram en ExecStart; None autodetecta por VRAM (GPUs con <= 8200 MiB -> True). Recomendado True en GPUs de 8 GB para modelos grandes (Flux, video)"
|
||||
- name: health_timeout
|
||||
desc: "segundos maximos sondeando GET /system_stats tras arrancar el servicio antes de declararlo no-sano (default 60)"
|
||||
- name: comfyui_dir
|
||||
desc: "raiz de la instalacion de ComfyUI; debe contener .venv/bin/python y main.py (default ~/ComfyUI, se expande y normaliza a absoluto)"
|
||||
- name: unit_name
|
||||
desc: "nombre del unit systemd-user (sin .service); el archivo va a ~/.config/systemd/user/<unit_name>.service (default 'comfyui')"
|
||||
- name: runner
|
||||
desc: "callable(cmd: list) -> CompletedProcess inyectable para tests; default ejecuta subprocess.run capturando salida"
|
||||
output: "dict con ok (bool: servicio activo y sano), active (ActiveState del unit: active|inactive|failed), port, health (bool: /system_stats respondio 2xx), error (str|None), lowvram (bool aplicado), unit_path (ruta del .service escrito), migrated (bool: paro un ComfyUI a mano para migrar a systemd), reloaded (bool: hubo daemon-reload), idempotent (bool: ya estaba activo+sano y no se toco nada)"
|
||||
tested: true
|
||||
tests:
|
||||
- "_detect_lowvram aplica el umbral de 8 GB (8192/8200 -> True, 8201/24564/None -> False)"
|
||||
- "_render_unit incluye Restart=always, WantedBy=default.target y nunca on-failure; anade --lowvram solo cuando corresponde"
|
||||
- "error claro si falta el venv python en comfyui_dir"
|
||||
- "idempotente: si is-active=active y /system_stats sano, no llama a start"
|
||||
- "arranque fresco: escribe el unit, daemon-reload + enable + start y espera salud"
|
||||
- "lowvram=False omite el flag --lowvram en el unit escrito"
|
||||
test_file_path: "python/functions/infra/comfyui_ensure_server_test.py"
|
||||
file_path: "python/functions/infra/comfyui_ensure_server.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.comfyui_ensure_server import comfyui_ensure_server
|
||||
|
||||
# Deja ComfyUI corriendo como servicio systemd-user, sano y con --lowvram
|
||||
# autodetectado en GPUs de 8 GB. Idempotente: relanzarla no rompe nada.
|
||||
res = comfyui_ensure_server(port=8188, lowvram=True)
|
||||
print(res)
|
||||
# {'ok': True, 'active': 'active', 'port': 8188, 'health': True, 'error': None,
|
||||
# 'lowvram': True, 'unit_path': '/home/enmanuel/.config/systemd/user/comfyui.service',
|
||||
# 'migrated': True, 'reloaded': True, 'idempotent': False}
|
||||
```
|
||||
|
||||
CLI directa (despacha por el venv del registry):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 python/functions/infra/comfyui_ensure_server.py --port=8188 --lowvram
|
||||
```
|
||||
|
||||
El usuario lo gestiona despues con systemd-user normal:
|
||||
|
||||
```bash
|
||||
systemctl --user status comfyui # estado + ultimos logs
|
||||
systemctl --user restart comfyui # reiniciar (la salud vuelve verde sola)
|
||||
systemctl --user stop comfyui # parar
|
||||
systemctl --user disable --now comfyui # revertir: para y deshabilita el arranque automatico
|
||||
journalctl --user -u comfyui -n 50 # diagnosticar fallos de arranque
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites que ComfyUI este garantizado arriba y sano antes de
|
||||
encolar workflows (txt2img, video, 3D), o para convertir el ComfyUI que hoy se
|
||||
relanza a mano en un servicio que arranca solo al boot y se reinicia si cae
|
||||
(gap del roadmap 0064). Es el primer paso del grupo `comfyui`: dejar el backend
|
||||
disponible; despues vienen `comfyui_build_*_workflow` + `comfyui_submit_workflow`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **systemd-user requiere linger** para sobrevivir al cierre de sesion / arrancar al boot: `loginctl enable-linger $USER`. Sin linger el unit solo vive mientras hay sesion activa. Si `enable` falla por esto, el dict lo dice en `error`.
|
||||
- **Migracion limpia con SIGTERM, nunca SIGKILL**: si ComfyUI ya corre a mano ocupando el puerto, la funcion lo para con SIGTERM y espera a que libere el bind (hasta ~25 s) antes de arrancar el servicio. Si el puerto lo ocupa un proceso que NO es ComfyUI (cmdline sin `main.py`), NO lo toca y devuelve `error` — no arranca para no duplicar el bind.
|
||||
- **Cambiar los flags del unit (p.ej. lowvram) NO reinicia un servicio ya sano**: la funcion reescribe el `.service` y hace daemon-reload, pero si el servicio ya esta active+healthy no lo reinicia para no interrumpir. Para aplicar flags nuevos: `systemctl --user restart comfyui`.
|
||||
- **Carga la GPU al arrancar**: levantar ComfyUI reserva VRAM. En una GPU de 8 GB compartida, evita lanzarlo mientras otra tarea pesada usa la GPU.
|
||||
- **Restart=always (no on-failure)**: un `systemctl --user stop` limpio es exit success; con `on-failure` el servicio reviviria solo tras crash. Para pararlo de verdad usa `stop` (no `restart`) o `disable --now`.
|
||||
- El health check es `GET http://127.0.0.1:<port>/system_stats` y espera 2xx; solo loopback.
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Garantiza que ComfyUI corre como servicio systemd-user resiliente y sano.
|
||||
|
||||
Funcion impura: instala/actualiza el unit systemd-user `comfyui.service`, lo
|
||||
habilita y arranca, y comprueba la salud del backend HTTP. Idempotente: si el
|
||||
servicio ya esta gestionado por systemd, activo y respondiendo, no toca nada.
|
||||
|
||||
Migracion limpia: si ComfyUI ya corre a mano (puerto ocupado por un proceso
|
||||
`main.py` que systemd NO gestiona), lo para con SIGTERM y lo levanta via
|
||||
systemd, para que a partir de ese momento se reinicie solo (Restart=always).
|
||||
|
||||
Solo depende de la stdlib (subprocess, urllib, os, signal, time, re). No lanza
|
||||
excepciones: siempre devuelve un dict de estado.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _default_runner(cmd):
|
||||
"""Ejecuta un comando capturando salida. Inyectable para tests."""
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def _detect_lowvram(vram_mib):
|
||||
"""Decide si conviene --lowvram segun la VRAM total en MiB.
|
||||
|
||||
GPUs con <= 8200 MiB (tarjetas de 8 GB) ganan estabilidad con --lowvram para
|
||||
modelos grandes (Flux, video). Si no hay dato de VRAM (None), NO asume
|
||||
lowvram: devuelve False para no penalizar GPUs grandes sin necesidad.
|
||||
"""
|
||||
return vram_mib is not None and vram_mib <= 8200
|
||||
|
||||
|
||||
def _query_vram_mib(runner):
|
||||
"""Lee la VRAM total (MiB) de la primera GPU via nvidia-smi. None si falla."""
|
||||
try:
|
||||
r = runner(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return int(r.stdout.strip().splitlines()[0].strip())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _render_unit(python_bin, main_py, working_dir, port, lowvram, description):
|
||||
"""Construye el texto del unit systemd-user. Pura (sin I/O)."""
|
||||
exec_start = f"{python_bin} {main_py} --port {port}"
|
||||
if lowvram:
|
||||
exec_start += " --lowvram"
|
||||
return (
|
||||
"[Unit]\n"
|
||||
f"Description={description}\n"
|
||||
"After=network-online.target\n"
|
||||
"Wants=network-online.target\n"
|
||||
"\n"
|
||||
"[Service]\n"
|
||||
"Type=simple\n"
|
||||
f"WorkingDirectory={working_dir}\n"
|
||||
f"ExecStart={exec_start}\n"
|
||||
# Restart=always (NO on-failure): un SIGTERM limpio es exit success y
|
||||
# con on-failure el servicio no reviviria. Ver .claude/rules/function_tags.md.
|
||||
"Restart=always\n"
|
||||
"RestartSec=5\n"
|
||||
"\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=default.target\n"
|
||||
)
|
||||
|
||||
|
||||
def _health(port, path="/system_stats", timeout=3):
|
||||
"""True si GET http://127.0.0.1:<port><path> responde 2xx."""
|
||||
url = f"http://127.0.0.1:{port}{path}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_health(port, timeout, interval=2.0):
|
||||
"""Sondea la salud hasta que responda 2xx o se agote el timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _health(port):
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return _health(port)
|
||||
|
||||
|
||||
def _systemctl(runner, *args):
|
||||
return runner(["systemctl", "--user", *args])
|
||||
|
||||
|
||||
def _unit_active_state(runner, unit_name):
|
||||
"""Devuelve el ActiveState del unit: active|inactive|failed|... o '' si no existe."""
|
||||
r = _systemctl(runner, "is-active", unit_name)
|
||||
return (r.stdout or r.stderr or "").strip()
|
||||
|
||||
|
||||
def _pid_listening_on_port(port, runner):
|
||||
"""PID del proceso que escucha en 127.0.0.1:<port>, o None. Via `ss`."""
|
||||
try:
|
||||
r = runner(["ss", "-ltnpH", f"sport = :{port}"])
|
||||
if r.returncode == 0:
|
||||
m = re.search(r"pid=(\d+)", r.stdout or "")
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _is_comfy_process(pid):
|
||||
"""True si la cmdline del PID contiene 'main.py' (proceso ComfyUI a mano)."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmd = f.read().replace(b"\0", b" ").decode(errors="replace")
|
||||
return "main.py" in cmd
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _terminate_manual(pid, port, runner, wait_s=25.0):
|
||||
"""SIGTERM al proceso a mano y espera a que libere el puerto. No usa SIGKILL."""
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
deadline = time.monotonic() + wait_s
|
||||
while time.monotonic() < deadline:
|
||||
if _pid_listening_on_port(port, runner) is None:
|
||||
return True
|
||||
time.sleep(1.0)
|
||||
# Reintento suave de SIGTERM antes de rendirse (nunca SIGKILL: no destructivo).
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3.0)
|
||||
return _pid_listening_on_port(port, runner) is None
|
||||
|
||||
|
||||
def comfyui_ensure_server(
|
||||
*,
|
||||
port=8188,
|
||||
lowvram=None,
|
||||
health_timeout=60,
|
||||
comfyui_dir="~/ComfyUI",
|
||||
unit_name="comfyui",
|
||||
runner=None,
|
||||
):
|
||||
"""Garantiza ComfyUI corriendo y sano como servicio systemd-user.
|
||||
|
||||
Args:
|
||||
port: puerto HTTP del backend ComfyUI (default 8188).
|
||||
lowvram: True/False fuerza el flag --lowvram; None autodetecta por VRAM
|
||||
(GPUs <= 8 GB -> True).
|
||||
health_timeout: segundos maximos esperando a que /system_stats responda
|
||||
tras arrancar el servicio.
|
||||
comfyui_dir: raiz de la instalacion de ComfyUI (con .venv/ y main.py).
|
||||
unit_name: nombre del unit systemd-user (sin .service).
|
||||
runner: callable(cmd:list)->CompletedProcess inyectable para tests.
|
||||
|
||||
Returns:
|
||||
dict con: ok, active (ActiveState), port, health (bool), error (str|None),
|
||||
lowvram (bool), unit_path, migrated (bool), reloaded (bool),
|
||||
idempotent (bool).
|
||||
"""
|
||||
runner = runner or _default_runner
|
||||
result = {
|
||||
"ok": False,
|
||||
"active": None,
|
||||
"port": port,
|
||||
"health": False,
|
||||
"error": None,
|
||||
"lowvram": None,
|
||||
"unit_path": None,
|
||||
"migrated": False,
|
||||
"reloaded": False,
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
comfyui_dir = os.path.abspath(os.path.expanduser(comfyui_dir))
|
||||
python_bin = os.path.join(comfyui_dir, ".venv", "bin", "python")
|
||||
main_py = os.path.join(comfyui_dir, "main.py")
|
||||
if not os.path.exists(python_bin):
|
||||
result["error"] = f"venv python no encontrado: {python_bin}"
|
||||
return result
|
||||
if not os.path.exists(main_py):
|
||||
result["error"] = f"main.py no encontrado: {main_py}"
|
||||
return result
|
||||
|
||||
# 1. Resolver lowvram (autodetect por VRAM si es None).
|
||||
lv = lowvram if lowvram is not None else _detect_lowvram(_query_vram_mib(runner))
|
||||
result["lowvram"] = bool(lv)
|
||||
|
||||
# 2. Renderizar e instalar el unit (solo reescribe si cambio el contenido).
|
||||
content = _render_unit(
|
||||
python_bin, main_py, comfyui_dir, port, lv,
|
||||
"ComfyUI (Stable Diffusion / Flux backend) gestionado por el registry",
|
||||
)
|
||||
unit_dir = os.path.expanduser("~/.config/systemd/user")
|
||||
try:
|
||||
os.makedirs(unit_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
result["error"] = f"no se pudo crear {unit_dir}: {e}"
|
||||
return result
|
||||
unit_path = os.path.join(unit_dir, f"{unit_name}.service")
|
||||
result["unit_path"] = unit_path
|
||||
|
||||
existing = None
|
||||
if os.path.exists(unit_path):
|
||||
try:
|
||||
with open(unit_path, "r") as f:
|
||||
existing = f.read()
|
||||
except Exception:
|
||||
existing = None
|
||||
changed = existing != content
|
||||
if changed:
|
||||
tmp = unit_path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.replace(tmp, unit_path)
|
||||
except Exception as e:
|
||||
result["error"] = f"no se pudo escribir el unit: {e}"
|
||||
return result
|
||||
rl = _systemctl(runner, "daemon-reload")
|
||||
result["reloaded"] = rl.returncode == 0
|
||||
if rl.returncode != 0:
|
||||
result["error"] = f"daemon-reload fallo: {(rl.stderr or '').strip()}"
|
||||
return result
|
||||
|
||||
# 3. Habilitar (idempotente; el linger del usuario ya debe estar activo).
|
||||
en = _systemctl(runner, "enable", unit_name)
|
||||
if en.returncode != 0:
|
||||
result["error"] = (
|
||||
f"systemctl --user enable {unit_name} fallo: "
|
||||
f"{(en.stderr or '').strip()}. "
|
||||
"Si es por falta de linger: `loginctl enable-linger $USER`."
|
||||
)
|
||||
return result
|
||||
|
||||
# 4. Estado actual: salud HTTP + si systemd ya lo gestiona.
|
||||
active_state = _unit_active_state(runner, unit_name)
|
||||
health_now = _health(port)
|
||||
|
||||
if health_now and active_state == "active":
|
||||
# Ya gestionado por systemd y sano -> idempotente, no tocar.
|
||||
result["ok"] = True
|
||||
result["health"] = True
|
||||
result["active"] = "active"
|
||||
result["idempotent"] = not changed
|
||||
return result
|
||||
|
||||
if health_now and active_state != "active":
|
||||
# Proceso a mano ocupa el puerto y systemd NO lo gestiona -> migrar limpio.
|
||||
pid = _pid_listening_on_port(port, runner)
|
||||
if pid and _is_comfy_process(pid):
|
||||
if not _terminate_manual(pid, port, runner):
|
||||
result["error"] = (
|
||||
f"no se pudo liberar el puerto {port} (PID {pid}) con SIGTERM; "
|
||||
"no arranco el servicio para no duplicar el bind."
|
||||
)
|
||||
return result
|
||||
result["migrated"] = True
|
||||
elif pid:
|
||||
result["error"] = (
|
||||
f"puerto {port} ocupado por PID {pid} que no parece ComfyUI; "
|
||||
"no lo toco ni arranco el servicio."
|
||||
)
|
||||
return result
|
||||
# Si pid es None pero health_now True: race raro; seguimos a start.
|
||||
|
||||
# 5. Arrancar via systemd y esperar salud.
|
||||
st = _systemctl(runner, "start", unit_name)
|
||||
if st.returncode != 0:
|
||||
result["active"] = _unit_active_state(runner, unit_name)
|
||||
result["error"] = (
|
||||
f"systemctl --user start {unit_name} fallo: "
|
||||
f"{(st.stderr or '').strip()}. Diagnostica con "
|
||||
f"`journalctl --user -u {unit_name} -n 50`."
|
||||
)
|
||||
return result
|
||||
|
||||
healthy = _wait_health(port, health_timeout)
|
||||
result["active"] = _unit_active_state(runner, unit_name)
|
||||
result["health"] = healthy
|
||||
result["ok"] = healthy
|
||||
if not healthy:
|
||||
result["error"] = (
|
||||
f"el unit arranco pero /system_stats no respondio 2xx en "
|
||||
f"{health_timeout}s. Revisa `journalctl --user -u {unit_name} -n 50`."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
kwargs = {}
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith("--port="):
|
||||
kwargs["port"] = int(arg.split("=", 1)[1])
|
||||
elif arg == "--lowvram":
|
||||
kwargs["lowvram"] = True
|
||||
elif arg == "--no-lowvram":
|
||||
kwargs["lowvram"] = False
|
||||
elif arg.startswith("--health-timeout="):
|
||||
kwargs["health_timeout"] = int(arg.split("=", 1)[1])
|
||||
elif arg.startswith("--comfyui-dir="):
|
||||
kwargs["comfyui_dir"] = arg.split("=", 1)[1]
|
||||
print(json.dumps(comfyui_ensure_server(**kwargs), indent=2))
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests para comfyui_ensure_server.
|
||||
|
||||
Los tests no tocan systemd ni la red reales: inyectan un runner falso que
|
||||
registra los comandos systemctl y se mockea el health check.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from . import comfyui_ensure_server as mod
|
||||
from .comfyui_ensure_server import (
|
||||
_detect_lowvram,
|
||||
_render_unit,
|
||||
comfyui_ensure_server,
|
||||
)
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
"""Runner inyectable: respuestas programables por prefijo de comando."""
|
||||
|
||||
def __init__(self, active_state="inactive"):
|
||||
self.calls = []
|
||||
self.active_state = active_state
|
||||
|
||||
def __call__(self, cmd):
|
||||
self.calls.append(list(cmd))
|
||||
# nvidia-smi VRAM
|
||||
if cmd[:1] == ["nvidia-smi"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="8192\n", stderr="")
|
||||
if cmd[:2] == ["systemctl", "--user"]:
|
||||
sub = cmd[2] if len(cmd) > 2 else ""
|
||||
if sub == "is-active":
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 0, stdout=self.active_state + "\n", stderr=""
|
||||
)
|
||||
# daemon-reload, enable, start -> exito
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
if cmd[:1] == ["ss"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
|
||||
def ran(self, *needle):
|
||||
return any(call[: len(needle)] == list(needle) for call in self.calls)
|
||||
|
||||
|
||||
def _fake_comfy_dir(tmp_path):
|
||||
"""Crea un comfyui_dir falso con .venv/bin/python y main.py."""
|
||||
d = tmp_path / "ComfyUI"
|
||||
(d / ".venv" / "bin").mkdir(parents=True)
|
||||
(d / ".venv" / "bin" / "python").write_text("#!/bin/sh\n")
|
||||
(d / "main.py").write_text("# fake\n")
|
||||
return d
|
||||
|
||||
|
||||
# --- helpers puros ---
|
||||
|
||||
def test_detect_lowvram_umbral_8gb():
|
||||
assert _detect_lowvram(8192) is True
|
||||
assert _detect_lowvram(8200) is True
|
||||
assert _detect_lowvram(8201) is False
|
||||
assert _detect_lowvram(24564) is False
|
||||
assert _detect_lowvram(None) is False
|
||||
|
||||
|
||||
def test_render_unit_restart_always_y_wantedby():
|
||||
unit = _render_unit(
|
||||
"/x/.venv/bin/python", "/x/main.py", "/x", 8188, True, "ComfyUI test"
|
||||
)
|
||||
assert "Restart=always" in unit
|
||||
assert "on-failure" not in unit # regla function_tags
|
||||
assert "WantedBy=default.target" in unit
|
||||
assert "ExecStart=/x/.venv/bin/python /x/main.py --port 8188 --lowvram" in unit
|
||||
assert "WorkingDirectory=/x" in unit
|
||||
|
||||
|
||||
def test_render_unit_sin_lowvram():
|
||||
unit = _render_unit(
|
||||
"/x/.venv/bin/python", "/x/main.py", "/x", 9000, False, "ComfyUI test"
|
||||
)
|
||||
assert "--lowvram" not in unit
|
||||
assert "--port 9000" in unit
|
||||
|
||||
|
||||
# --- orquestacion (runner falso + health mockeado) ---
|
||||
|
||||
def test_error_si_falta_venv(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
res = comfyui_ensure_server(
|
||||
comfyui_dir=str(tmp_path / "no_existe"), runner=FakeRunner()
|
||||
)
|
||||
assert res["ok"] is False
|
||||
assert "venv python no encontrado" in res["error"]
|
||||
|
||||
|
||||
def test_idempotente_si_ya_activo_y_sano(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setattr(mod, "_health", lambda *a, **k: True)
|
||||
d = _fake_comfy_dir(tmp_path)
|
||||
runner = FakeRunner(active_state="active")
|
||||
|
||||
# 1a llamada: instala el unit por primera vez (changed -> idempotent False),
|
||||
# pero como ya esta active+sano NO debe arrancar nada.
|
||||
res1 = comfyui_ensure_server(comfyui_dir=str(d), runner=runner)
|
||||
assert res1["ok"] is True
|
||||
assert res1["health"] is True
|
||||
assert res1["active"] == "active"
|
||||
assert res1["idempotent"] is False # escribio el unit por primera vez
|
||||
assert not runner.ran("systemctl", "--user", "start", "comfyui")
|
||||
|
||||
# 2a llamada: el unit ya existe identico -> no toca nada -> idempotent True.
|
||||
runner2 = FakeRunner(active_state="active")
|
||||
res2 = comfyui_ensure_server(comfyui_dir=str(d), runner=runner2)
|
||||
assert res2["ok"] is True
|
||||
assert res2["idempotent"] is True
|
||||
assert res2["reloaded"] is False # no reescribio el unit
|
||||
assert not runner2.ran("systemctl", "--user", "start", "comfyui")
|
||||
|
||||
|
||||
def test_arranque_fresco_escribe_unit_y_arranca(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# health: False antes de arrancar, True despues
|
||||
estados = iter([False, True, True, True])
|
||||
monkeypatch.setattr(mod, "_health", lambda *a, **k: next(estados, True))
|
||||
d = _fake_comfy_dir(tmp_path)
|
||||
runner = FakeRunner(active_state="inactive")
|
||||
res = comfyui_ensure_server(comfyui_dir=str(d), runner=runner, health_timeout=5)
|
||||
assert res["ok"] is True
|
||||
assert res["health"] is True
|
||||
assert res["lowvram"] is True # nvidia-smi falso devuelve 8192
|
||||
assert runner.ran("systemctl", "--user", "daemon-reload")
|
||||
assert runner.ran("systemctl", "--user", "enable", "comfyui")
|
||||
assert runner.ran("systemctl", "--user", "start", "comfyui")
|
||||
# el unit quedo escrito
|
||||
unit_path = os.path.join(
|
||||
str(tmp_path), ".config", "systemd", "user", "comfyui.service"
|
||||
)
|
||||
assert os.path.exists(unit_path)
|
||||
with open(unit_path) as f:
|
||||
assert "Restart=always" in f.read()
|
||||
|
||||
|
||||
def test_lowvram_forzado_false_omite_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
estados = iter([False, True])
|
||||
monkeypatch.setattr(mod, "_health", lambda *a, **k: next(estados, True))
|
||||
d = _fake_comfy_dir(tmp_path)
|
||||
runner = FakeRunner(active_state="inactive")
|
||||
res = comfyui_ensure_server(
|
||||
comfyui_dir=str(d), runner=runner, lowvram=False, health_timeout=5
|
||||
)
|
||||
assert res["lowvram"] is False
|
||||
unit_path = os.path.join(
|
||||
str(tmp_path), ".config", "systemd", "user", "comfyui.service"
|
||||
)
|
||||
with open(unit_path) as f:
|
||||
assert "--lowvram" not in f.read()
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)}
|
||||
@@ -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_fp16.safetensors", # 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.4–0.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))
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: comfyui_build_flux_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_flux_workflow(prompt: str, *, unet: str = \"flux1-schnell-fp8-e4m3fn.safetensors\", clip_l: str = \"clip_l.safetensors\", t5xxl: str = \"t5xxl_fp8_e4m3fn_scaled.safetensors\", vae: str = \"ae.safetensors\", width: int = 1024, height: int = 1024, steps: int = 4, guidance: float = 3.5, seed: int = 0, weight_dtype: str = \"fp8_e4m3fn\", sampler_name: str = \"euler\", scheduler: str = \"simple\", filename_prefix: str = \"comfy_flux\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI txt2img con Flux en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]). A diferencia de SD1.5/SDXL, Flux carga por separado UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader; la guia va por FluxGuidance (no por el cfg del KSampler, que se fija a 1.0). Cadena: UNETLoader+DualCLIPLoader+VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, flux, ml, txt2img, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "Prompt positivo: lo que se quiere ver en la imagen."
|
||||
- name: unet
|
||||
desc: "Nombre del modelo de difusion en models/diffusion_models/ tal como lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto el Flux schnell fp8. keyword-only."
|
||||
- name: clip_l
|
||||
desc: "Nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del DualCLIPLoader). Por defecto 'clip_l.safetensors'. keyword-only."
|
||||
- name: t5xxl
|
||||
desc: "Nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del DualCLIPLoader). Por defecto 't5xxl_fp8_e4m3fn_scaled.safetensors'. keyword-only."
|
||||
- name: vae
|
||||
desc: "Nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto 'ae.safetensors', el autoencoder de Flux. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. Flux schnell rinde con ~4; Flux dev necesita ~20. keyword-only."
|
||||
- name: guidance
|
||||
desc: "Valor del nodo FluxGuidance (no es el cfg clasico). Schnell es poco sensible; dev responde a 3.0-4.0. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
|
||||
- name: weight_dtype
|
||||
desc: "dtype de carga del UNET (uno de 'default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). fp8 reduce VRAM, clave en GPU de 8GB. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (Flux usa 'euler'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (Flux usa 'simple'). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||
output: "dict en API format con node_ids como claves (UNETLoader '10', DualCLIPLoader '11', VAELoader '12', CLIPTextEncode positivo '6', FluxGuidance '13', CLIPTextEncode negativo vacio '7', EmptySD3LatentImage '5', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["class_types esperados (9 nodos de Flux)", "loaders separados UNET+DualCLIP(flux)+VAE", "guidance via FluxGuidance y cfg del KSampler fijado a 1.0", "params width/height/steps/seed reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_flux_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_flux_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_flux_workflow import comfyui_build_flux_workflow
|
||||
|
||||
wf = comfyui_build_flux_workflow(
|
||||
prompt="a red apple on a wooden table, sharp focus, studio lighting",
|
||||
width=1024,
|
||||
height=1024,
|
||||
steps=4, # Flux schnell: ~4 pasos basta
|
||||
seed=42,
|
||||
)
|
||||
# wf["10"]["class_type"] == "UNETLoader" # modelo de difusion suelto
|
||||
# wf["11"]["inputs"]["type"] == "flux" # DualCLIPLoader en modo flux
|
||||
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler consume FluxGuidance
|
||||
# wf["3"]["inputs"]["cfg"] == 1.0 # la guia va por FluxGuidance
|
||||
# wf["9"]["class_type"] == "SaveImage"
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_flux_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando vayas a generar txt2img con un modelo Flux (schnell o dev) y necesites el
|
||||
dict del workflow para `comfyui_submit_workflow`. Usala en lugar de
|
||||
`comfyui_build_txt2img_workflow` siempre que el modelo NO sea un checkpoint
|
||||
todo-en-uno SD1.5/SDXL sino Flux con UNET + text encoders + VAE por separado.
|
||||
Flux schnell es ideal en GPU de poca VRAM (8GB) por el fp8 y los ~4 pasos.
|
||||
|
||||
## 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.
|
||||
- Flux NO usa el cfg del KSampler para guiar: este builder lo fija a 1.0 y la
|
||||
guia va por el nodo FluxGuidance. Subir el cfg del KSampler con Flux degrada o
|
||||
rompe la imagen.
|
||||
- El negativo es un CLIPTextEncode vacio cableado al KSampler (igual que el
|
||||
template oficial de Flux). Flux schnell es destilado y practicamente ignora el
|
||||
negativo; no esperes que un prompt negativo tenga el efecto de SD1.5/SDXL.
|
||||
- `unet`, `clip_l`, `t5xxl` y `vae` deben existir en los directorios respectivos
|
||||
visibles para el servidor (models/diffusion_models/, models/text_encoders/,
|
||||
models/vae/). Si no, ComfyUI rechaza el workflow con HTTP 400 al enviarlo (no
|
||||
aqui — esta funcion es pura y no valida contra el servidor). Valida antes con
|
||||
`comfyui_validate_workflow`.
|
||||
- `width`/`height` deben ser multiplos de 16 para EmptySD3LatentImage (Flux), no
|
||||
de 8 como en SD1.5/SDXL.
|
||||
- `weight_dtype` debe ser uno de los que admite UNETLoader ('default',
|
||||
'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). En 8GB usa fp8 o el modelo no
|
||||
cabe en VRAM.
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Construye un workflow ComfyUI txt2img con Flux 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).
|
||||
|
||||
A diferencia del builder SD1.5/SDXL (comfyui_build_txt2img_workflow), Flux NO usa
|
||||
un checkpoint todo-en-uno: carga por separado el modelo de difusion (UNETLoader),
|
||||
los dos text encoders (DualCLIPLoader con clip_l + t5xxl, type="flux") y el VAE
|
||||
(VAELoader). La guia no va por el cfg del KSampler (que se fija a 1.0) sino por el
|
||||
nodo FluxGuidance aplicado al condicionamiento positivo. El negativo se deja como
|
||||
un CLIPTextEncode vacio, igual que el template oficial de Flux en ComfyUI.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_flux_workflow(
|
||||
prompt: str,
|
||||
*,
|
||||
unet: str = "flux1-schnell-fp8-e4m3fn.safetensors",
|
||||
clip_l: str = "clip_l.safetensors",
|
||||
t5xxl: str = "t5xxl_fp8_e4m3fn_scaled.safetensors",
|
||||
vae: str = "ae.safetensors",
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
steps: int = 4,
|
||||
guidance: float = 3.5,
|
||||
seed: int = 0,
|
||||
weight_dtype: str = "fp8_e4m3fn",
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "simple",
|
||||
filename_prefix: str = "comfy_flux",
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow txt2img de Flux (schnell/dev).
|
||||
|
||||
Cadena de nodos: UNETLoader + DualCLIPLoader + VAELoader -> CLIPTextEncode
|
||||
(positivo) -> FluxGuidance, mas un CLIPTextEncode vacio para el negativo y
|
||||
EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage.
|
||||
|
||||
Args:
|
||||
prompt: prompt positivo (lo que se quiere ver en la imagen).
|
||||
unet: nombre del modelo de difusion en models/diffusion_models/ tal como
|
||||
lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto
|
||||
el Flux schnell fp8 ("flux1-schnell-fp8-e4m3fn.safetensors").
|
||||
clip_l: nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del
|
||||
DualCLIPLoader). Por defecto "clip_l.safetensors".
|
||||
t5xxl: nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del
|
||||
DualCLIPLoader). Por defecto "t5xxl_fp8_e4m3fn_scaled.safetensors".
|
||||
vae: nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto
|
||||
"ae.safetensors" (el autoencoder de Flux).
|
||||
width: ancho del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
|
||||
height: alto del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
|
||||
steps: pasos de sampling del KSampler. Flux schnell rinde bien con ~4;
|
||||
Flux dev necesita ~20. keyword-only.
|
||||
guidance: valor del nodo FluxGuidance (no es el cfg clasico). Schnell es
|
||||
poco sensible a este valor; dev responde a 3.0-4.0. keyword-only.
|
||||
seed: semilla del KSampler (0 = determinista; cambia para variar). keyword-only.
|
||||
weight_dtype: dtype de carga del UNET (uno de "default", "fp8_e4m3fn",
|
||||
"fp8_e4m3fn_fast", "fp8_e5m2"). fp8 reduce VRAM (clave en 8GB). keyword-only.
|
||||
sampler_name: nombre del sampler (Flux usa "euler"). keyword-only.
|
||||
scheduler: scheduler del sampler (Flux usa "simple"). keyword-only.
|
||||
filename_prefix: prefijo del PNG que SaveImage escribe en output/. 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.
|
||||
"""
|
||||
return {
|
||||
"10": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": unet, "weight_dtype": weight_dtype},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "DualCLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name1": t5xxl,
|
||||
"clip_name2": clip_l,
|
||||
"type": "flux",
|
||||
},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": vae},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt, "clip": ["11", 0]},
|
||||
},
|
||||
"13": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {"conditioning": ["6", 0], "guidance": guidance},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "", "clip": ["11", 0]},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": ["10", 0],
|
||||
"positive": ["13", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["12", 0]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_flux_workflow(
|
||||
prompt="a red apple on a wooden table, sharp focus, studio lighting",
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -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.
|
||||
@@ -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.3–0.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.5–2.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,107 @@
|
||||
---
|
||||
name: comfyui_build_img2vid_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_img2vid_workflow(image: str, *, ckpt: str = \"svd.safetensors\", width: int = 1024, height: int = 576, video_frames: int = 14, motion_bucket_id: int = 127, fps: int = 6, augmentation_level: float = 0.0, steps: int = 20, cfg: float = 2.5, min_cfg: float = 1.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"karras\", filename_prefix: str = \"comfy_svd\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI img2vid (Stable Video Diffusion) en API format a partir de una imagen estatica. Cadena: ImageOnlyCheckpointLoader(svd.safetensors -> MODEL, CLIP_VISION, VAE) + LoadImage -> SVD_img2vid_Conditioning(positive, negative, latent) -> VideoLinearCFGGuidance -> KSampler(denoise 1.0) -> VAEDecode -> SaveAnimatedWEBP. SVD no usa prompt de texto: el condicionamiento sale de la imagen via CLIP_VISION del checkpoint todo-en-uno. Movimiento via motion_bucket_id y fps. Pura, sin red ni I/O. Hermana de comfyui_build_video_workflow (txt2video LTX/Wan)."
|
||||
tags: [comfyui, svd, img2vid, video, ml, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: image
|
||||
desc: "Nombre del archivo de imagen base en la carpeta input/ del servidor ComfyUI (lo que carga LoadImage). Es el frame inicial del que SVD deriva el clip."
|
||||
- name: ckpt
|
||||
desc: "Nombre del checkpoint SVD tal como lo ve el servidor. Por defecto 'svd.safetensors' (todo-en-uno: UNet + VAE + CLIP image encoder). keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del video en px (multiplo de 8; SVD base entrena a 1024). keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del video en px (multiplo de 8; SVD base entrena a 576). keyword-only."
|
||||
- name: video_frames
|
||||
desc: "Numero de frames del clip. svd.safetensors es el modelo de 14 frames; la variante xt llega a 25. keyword-only."
|
||||
- name: motion_bucket_id
|
||||
desc: "Intensidad de movimiento (1-255 util; 127 por defecto). Mas alto = mas movimiento. keyword-only."
|
||||
- name: fps
|
||||
desc: "Frames por segundo con que se condiciona (SVD_img2vid_Conditioning) y se guarda el clip (SaveAnimatedWEBP, alli como float). keyword-only."
|
||||
- name: augmentation_level
|
||||
desc: "Ruido anadido a la imagen base (0.0 = fiel; subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de min_cfg al primero hasta cfg al ultimo). SVD usa cfg baja (~2.5). keyword-only."
|
||||
- name: min_cfg
|
||||
desc: "Guidance scale del primer frame para VideoLinearCFGGuidance. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del KSampler. Por defecto 'karras'. keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida (.webp animado de SaveAnimatedWEBP). keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 7 nodos: ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning, VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise del KSampler se fija a 1.0 (genera desde el latente condicionado, no es img2img)."
|
||||
tested: true
|
||||
tests: ["estructura: 7 nodos SVD presentes + ckpt svd.safetensors + image en LoadImage", "cableado: clip_vision/vae [15,1]/[15,2], cond->KSampler 0/1/2, model post VideoLinearCFGGuidance, denoise 1.0", "params reflejados (width/height/video_frames/motion_bucket_id/fps/augmentation_level/steps/cfg/min_cfg/seed/filename_prefix) + fps float en SaveAnimatedWEBP", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2vid_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_img2vid_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_img2vid_workflow import comfyui_build_img2vid_workflow
|
||||
|
||||
wf = comfyui_build_img2vid_workflow(
|
||||
"example.png",
|
||||
width=1024, height=576, video_frames=14,
|
||||
motion_bucket_id=127, fps=6, steps=20, seed=42,
|
||||
)
|
||||
# wf["12"]["class_type"] == "SVD_img2vid_Conditioning"
|
||||
# wf["30"]["class_type"] == "SaveAnimatedWEBP"
|
||||
# -> comfyui_submit_workflow(wf) para encolar el clip (necesita GPU)
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_img2vid_workflow` (imprime el JSON del workflow SVD de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de enviar una generacion de video img2vid (animar una imagen estatica) a
|
||||
ComfyUI: construye aqui el dict del workflow SVD y pasalo a
|
||||
`comfyui_submit_workflow`. Usala cuando partes de UNA imagen y quieres un clip
|
||||
corto derivado de ella (SVD no toma prompt de texto). Para texto -> video usa la
|
||||
hermana `comfyui_build_video_workflow` (LTX/Wan). Verifica el workflow contra el
|
||||
servidor con `comfyui_validate_workflow` antes de encolar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
|
||||
acepta POST /prompt.
|
||||
- SVD NO usa prompts de texto. El condicionamiento sale de la imagen base via el
|
||||
CLIP_VISION del checkpoint todo-en-uno; por eso no hay nodos CLIPTextEncode.
|
||||
- El checkpoint `svd.safetensors` debe existir y ser visible para el servidor
|
||||
(carpeta de checkpoints o extra_model_paths) o ComfyUI rechaza el workflow con
|
||||
HTTP 400 al enviarlo. Esta funcion es pura y no valida contra el servidor.
|
||||
- La imagen `image` debe estar en la carpeta input/ del servidor (subela antes con
|
||||
el endpoint de upload o el nodo LoadImage de la UI). El validador estructural NO
|
||||
comprueba la existencia de la imagen (image no es un input de modelo).
|
||||
- VRAM 8GB: SVD es pesado. Con los defaults (1024x576, 14 frames) el modelo base
|
||||
puede acercarse al techo de 8GB. Si da OOM, bajar resolucion (768x448) o
|
||||
video_frames. La generacion real (submit) es un paso posterior con GPU; este
|
||||
builder solo arma el dict y se valida de forma estructural (offline).
|
||||
- `svd.safetensors` es el modelo de 14 frames. La variante `svd_xt` admite 25;
|
||||
con el base, video_frames > 14 puede degradar el clip.
|
||||
- motion_bucket_id alto = mas movimiento (y mas artefactos). 127 es el centro
|
||||
recomendado por Stability.
|
||||
- cfg se mantiene baja (~2.5) y se interpola con VideoLinearCFGGuidance (min_cfg en
|
||||
el primer frame -> cfg en el ultimo). Subir cfg degrada el video.
|
||||
- SaveAnimatedWEBP declara `fps` como FLOAT en /object_info: el builder pasa
|
||||
`float(fps)` para no provocar HTTP 400. El nodo VHS_VideoCombine NO esta instalado
|
||||
en este servidor; por eso el guardado usa el SaveAnimatedWEBP nativo.
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Construye un workflow ComfyUI img2vid (SVD) en "API format" (dict de nodos numerados).
|
||||
|
||||
Implementa la plantilla canonica de Stable Video Diffusion de ComfyUI: a partir de
|
||||
una imagen estatica genera un clip corto de video. El checkpoint `svd.safetensors`
|
||||
es todo-en-uno (UNet + VAE + CLIP image encoder), cargado con
|
||||
ImageOnlyCheckpointLoader (da MODEL, CLIP_VISION y VAE de una sola pieza).
|
||||
|
||||
Cadena de nodos:
|
||||
ImageOnlyCheckpointLoader (MODEL, CLIP_VISION, VAE) + LoadImage (imagen base) ->
|
||||
SVD_img2vid_Conditioning (positive, negative, latent) ->
|
||||
VideoLinearCFGGuidance (interpola cfg de min_cfg a cfg a lo largo del clip) ->
|
||||
KSampler (denoise 1.0) -> VAEDecode (secuencia de frames) -> SaveAnimatedWEBP.
|
||||
|
||||
A diferencia de los modelos txt2video (LTX/Wan), SVD no usa prompts de texto: el
|
||||
condicionamiento sale de la imagen via el CLIP_VISION del propio checkpoint. El
|
||||
movimiento se controla con motion_bucket_id (mas alto = mas movimiento) y fps.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_img2vid_workflow(
|
||||
image: str,
|
||||
*,
|
||||
ckpt: str = "svd.safetensors",
|
||||
width: int = 1024,
|
||||
height: int = 576,
|
||||
video_frames: int = 14,
|
||||
motion_bucket_id: int = 127,
|
||||
fps: int = 6,
|
||||
augmentation_level: float = 0.0,
|
||||
steps: int = 20,
|
||||
cfg: float = 2.5,
|
||||
min_cfg: float = 1.0,
|
||||
seed: int = 0,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "karras",
|
||||
filename_prefix: str = "comfy_svd",
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow img2vid (SVD) para svd.safetensors.
|
||||
|
||||
Args:
|
||||
image: nombre del archivo de imagen base dentro de la carpeta input/ del
|
||||
servidor ComfyUI (lo que carga el nodo LoadImage). Es el frame inicial
|
||||
del que SVD deriva el clip.
|
||||
ckpt: nombre del checkpoint SVD tal como lo ve el servidor. Por defecto
|
||||
"svd.safetensors" (todo-en-uno: UNet + VAE + CLIP image encoder).
|
||||
keyword-only.
|
||||
width: ancho del video en px (multiplo de 8; SVD base entrena a 1024).
|
||||
keyword-only.
|
||||
height: alto del video en px (multiplo de 8; SVD base entrena a 576).
|
||||
keyword-only.
|
||||
video_frames: numero de frames del clip. svd.safetensors es el modelo de
|
||||
14 frames; el variante xt llega a 25. keyword-only.
|
||||
motion_bucket_id: intensidad de movimiento (1-255 util; 127 por defecto).
|
||||
Mas alto = mas movimiento. keyword-only.
|
||||
fps: frames por segundo con que se condiciona y se guarda el clip.
|
||||
keyword-only.
|
||||
augmentation_level: ruido anadido a la imagen base (0.0 = fiel a la base;
|
||||
subirlo da mas libertad/movimiento a costa de fidelidad). keyword-only.
|
||||
steps: pasos de sampling del KSampler. keyword-only.
|
||||
cfg: guidance scale del ultimo frame (VideoLinearCFGGuidance interpola de
|
||||
min_cfg al primer frame hasta cfg al ultimo). SVD usa cfg baja (~2.5).
|
||||
keyword-only.
|
||||
min_cfg: guidance scale del primer frame para VideoLinearCFGGuidance.
|
||||
keyword-only.
|
||||
seed: semilla del sampler (0 = determinista; cambiar para variar el clip).
|
||||
keyword-only.
|
||||
sampler_name: algoritmo del KSampler. Por defecto "euler". keyword-only.
|
||||
scheduler: scheduler del KSampler. Por defecto "karras". keyword-only.
|
||||
filename_prefix: prefijo del archivo de salida (.webp animado).
|
||||
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. Devuelve 7 nodos:
|
||||
ImageOnlyCheckpointLoader, LoadImage, SVD_img2vid_Conditioning,
|
||||
VideoLinearCFGGuidance, KSampler, VAEDecode y SaveAnimatedWEBP. El denoise
|
||||
del KSampler se fija a 1.0 (img2vid genera desde latente vacio condicionado,
|
||||
no es img2img).
|
||||
"""
|
||||
return {
|
||||
"15": {
|
||||
"class_type": "ImageOnlyCheckpointLoader",
|
||||
"inputs": {"ckpt_name": ckpt},
|
||||
},
|
||||
"23": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": image},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "SVD_img2vid_Conditioning",
|
||||
"inputs": {
|
||||
"clip_vision": ["15", 1],
|
||||
"init_image": ["23", 0],
|
||||
"vae": ["15", 2],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"video_frames": video_frames,
|
||||
"motion_bucket_id": motion_bucket_id,
|
||||
"fps": fps,
|
||||
"augmentation_level": augmentation_level,
|
||||
},
|
||||
},
|
||||
"14": {
|
||||
"class_type": "VideoLinearCFGGuidance",
|
||||
"inputs": {"model": ["15", 0], "min_cfg": min_cfg},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": ["14", 0],
|
||||
"positive": ["12", 0],
|
||||
"negative": ["12", 1],
|
||||
"latent_image": ["12", 2],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["15", 2]},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "SaveAnimatedWEBP",
|
||||
"inputs": {
|
||||
"images": ["8", 0],
|
||||
"filename_prefix": filename_prefix,
|
||||
"fps": float(fps),
|
||||
"lossless": False,
|
||||
"quality": 90,
|
||||
"method": "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_img2vid_workflow(
|
||||
"example.png",
|
||||
motion_bucket_id=127,
|
||||
fps=6,
|
||||
video_frames=14,
|
||||
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,118 @@
|
||||
---
|
||||
name: comfyui_build_ipadapter_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_ipadapter_workflow(prompt: str, ref_image: str, *, base_checkpoint: str, mode: str = 'style', weight: float = 0.8, negative: str = '', preset: str | None = None, weight_type: str | None = None, start_at: float = 0.0, end_at: float = 1.0, weight_faceidv2: float = 1.0, lora_strength: float = 0.6, combine_embeds: str = 'concat', embeds_scaling: str = 'V only', provider: str = 'CPU', 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 = 'ipadapter') -> dict"
|
||||
description: "Construye un workflow ComfyUI txt2img + IPAdapter (custom node cubiq/IPAdapter_plus) en API format. mode='style' usa IPAdapterUnifiedLoader+IPAdapter para transferir estilo/composicion de una imagen de referencia; mode='faceid' usa IPAdapterUnifiedLoaderFaceID+IPAdapterFaceID (insightface + .bin FaceID + su LoRA) para imponer un rostro consistente. Repunta el KSampler a la salida MODEL de la rama IPAdapter. Pura: sin red ni I/O."
|
||||
tags: [comfyui, comfyui-skill, ipadapter, faceid, ml, stable-diffusion, workflow]
|
||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "Prompt positivo (texto del resultado deseado)."
|
||||
- name: ref_image
|
||||
desc: "Nombre del archivo de imagen de referencia en input/ del servidor ComfyUI (lo carga LoadImage). En faceid debe contener una cara nitida; en style es la imagen de estilo."
|
||||
- name: base_checkpoint
|
||||
desc: "Checkpoint SD1.5/SDXL. Debe casar con los modelos IPAdapter (modelos SD1.5 con checkpoints SD1.5). keyword-only."
|
||||
- name: mode
|
||||
desc: "'style' (transfiere estilo/composicion) o 'faceid' (rostro consistente). keyword-only."
|
||||
- name: weight
|
||||
desc: "Peso de la influencia IPAdapter (0..1+). 0.8 buen punto de partida; sube para mas parecido, baja para mas libertad del prompt."
|
||||
- name: negative
|
||||
desc: "Prompt negativo."
|
||||
- name: preset
|
||||
desc: "Preset del UnifiedLoader. None => default por modo ('STANDARD (medium strength)' style, 'FACEID PLUS V2' faceid)."
|
||||
- name: weight_type
|
||||
desc: "Tipo de ponderacion del nodo IPAdapter/FaceID. None => default por modo ('standard' style, 'linear' faceid)."
|
||||
- name: start_at
|
||||
desc: "Fraccion del sampling donde empieza a aplicar IPAdapter (0..1)."
|
||||
- name: end_at
|
||||
desc: "Fraccion del sampling donde deja de aplicar (0..1)."
|
||||
- name: weight_faceidv2
|
||||
desc: "Peso del embedding FaceID v2 (solo mode='faceid')."
|
||||
- name: lora_strength
|
||||
desc: "Fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID (solo mode='faceid')."
|
||||
- name: combine_embeds
|
||||
desc: "Combinacion de embeddings si hay varias caras ('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid."
|
||||
- name: embeds_scaling
|
||||
desc: "Escalado de embeddings ('V only'|'K+V'|...). Solo faceid."
|
||||
- name: provider
|
||||
desc: "Backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para no competir por VRAM. Solo faceid."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling (pasa a la base txt2img)."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale (pasa a la base)."
|
||||
- name: width
|
||||
desc: "Ancho en px, multiplo de 8 (pasa a la base)."
|
||||
- name: height
|
||||
desc: "Alto en px, multiplo de 8 (pasa a la base)."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler (pasa a la base)."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (pasa a la base)."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (pasa a la base)."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG generado por SaveImage."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: base txt2img + LoadImage + rama IPAdapter del modo elegido, con el KSampler repuntado a la salida MODEL de esa rama."
|
||||
tested: true
|
||||
tests: ["mode='style': nodos LoadImage/IPAdapterUnifiedLoader/IPAdapter + conexiones + KSampler repuntado + defaults", "mode='faceid': nodos UnifiedLoaderFaceID/IPAdapterFaceID + conexiones + provider CPU + defaults", "mode invalido lanza ValueError", "ref_image vacia lanza ValueError", "override de preset y weight_type", "determinismo"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_ipadapter_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_ipadapter_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_ipadapter_workflow import comfyui_build_ipadapter_workflow
|
||||
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||
|
||||
# Estilo: transfiere el look de una imagen de referencia
|
||||
wf = comfyui_build_ipadapter_workflow(
|
||||
"a fantasy castle on a hill", "example.png",
|
||||
base_checkpoint="dreamshaper_8.safetensors", mode="style", weight=0.8)
|
||||
resp = comfyui_submit_workflow(wf)
|
||||
|
||||
# FaceID: rostro consistente a partir de una cara de referencia
|
||||
wf = comfyui_build_ipadapter_workflow(
|
||||
"portrait of a knight in armor, cinematic", "showcase_char.png",
|
||||
base_checkpoint="dreamshaper_8.safetensors", mode="faceid", weight=0.9)
|
||||
resp = comfyui_submit_workflow(wf)
|
||||
print(resp["prompt_id"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras condicionar una generacion por una **imagen de referencia**, no solo
|
||||
texto. Dos casos: `mode='style'` para clonar el estilo/composicion de una imagen
|
||||
(image prompt), y `mode='faceid'` para generar un personaje con un **rostro
|
||||
concreto y consistente** (el modelo extrae el embedding facial con insightface).
|
||||
La referencia se sube primero a `input/` del servidor (LoadImage la lee por nombre).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Modelos SD1.5 ↔ checkpoints SD1.5.** Los modelos descargados son SD1.5
|
||||
(`ip-adapter*_sd15`, `ip-adapter-faceid-plusv2_sd15`); usalos con un checkpoint
|
||||
SD1.5 (dreamshaper_8). Mezclar con SDXL hace fallar el UnifiedLoader.
|
||||
- **La clave `ipadapter` debe estar en `extra_model_paths.yaml`.** El custom node
|
||||
registra la carpeta `models/ipadapter`; si los modelos viven en otra ruta (ej.
|
||||
`/mnt/2tb`), esa clave los mapea. Sin ella `ipadapter_file` sale vacio.
|
||||
- **faceid usa insightface (`buffalo_l`) + la LoRA FaceID.** El UnifiedLoaderFaceID
|
||||
carga la LoRA `ip-adapter-faceid-plusv2_sd15_lora.safetensors` (debe estar en
|
||||
`models/loras/`). `provider='CPU'` por defecto: insightface en CPU no compite por
|
||||
los 8GB de VRAM; pon `'CUDA'` solo si tienes onnxruntime-gpu instalado.
|
||||
- **La referencia debe existir en `input/`.** Es un nombre de archivo, no una ruta:
|
||||
sube la imagen antes (POST /upload/image o copiala a `~/ComfyUI/input/`).
|
||||
- Pura: construye el dict, no valida que los modelos existan ni hace red. Valida con
|
||||
`comfyui_validate_workflow` y envia con `comfyui_submit_workflow`.
|
||||
- En 8GB usa resolucion modesta (512x512) en SD1.5; faceid + LoRA + insightface
|
||||
caben con `--lowvram`, pero sube la VRAM si combinas con multi-LoRA pesado.
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Construye un workflow ComfyUI txt2img + IPAdapter en API format (dict de nodos).
|
||||
|
||||
Parte de comfyui_build_txt2img_workflow y le injerta la rama IPAdapter del custom
|
||||
node ComfyUI_IPAdapter_plus (cubiq):
|
||||
|
||||
- mode='style': IPAdapterUnifiedLoader + IPAdapter. La imagen de referencia
|
||||
transfiere estilo/composicion al resultado (image prompt clasico).
|
||||
- mode='faceid': IPAdapterUnifiedLoaderFaceID + IPAdapterFaceID. Usa insightface
|
||||
para extraer el embedding de la cara de la referencia y el .bin FaceID + su
|
||||
LoRA para imponer un **rostro consistente** en el personaje generado.
|
||||
|
||||
En ambos casos la salida MODEL de la rama IPAdapter se repunta al KSampler, de
|
||||
modo que el sampler genera ya condicionado por la imagen de referencia.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. Los
|
||||
class_type/inputs estan verificados contra /object_info del servidor (IPAdapter
|
||||
plus): IPAdapterUnifiedLoader(model,preset)->[MODEL,IPADAPTER],
|
||||
IPAdapter(model,ipadapter,image,weight,start_at,end_at,weight_type)->[MODEL],
|
||||
IPAdapterUnifiedLoaderFaceID(model,preset,lora_strength,provider)->[MODEL,IPADAPTER],
|
||||
IPAdapterFaceID(model,ipadapter,image,weight,weight_faceidv2,weight_type,
|
||||
combine_embeds,start_at,end_at,embeds_scaling)->[MODEL,IMAGE].
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Presets por defecto del IPAdapterUnifiedLoader(FaceID) segun el modo.
|
||||
_DEFAULT_PRESET = {
|
||||
"style": "STANDARD (medium strength)",
|
||||
"faceid": "FACEID PLUS V2",
|
||||
}
|
||||
# weight_type por defecto: el nodo IPAdapter usa 'standard', el FaceID usa 'linear'.
|
||||
_DEFAULT_WEIGHT_TYPE = {
|
||||
"style": "standard",
|
||||
"faceid": "linear",
|
||||
}
|
||||
|
||||
|
||||
def comfyui_build_ipadapter_workflow(
|
||||
prompt: str,
|
||||
ref_image: str,
|
||||
*,
|
||||
base_checkpoint: str,
|
||||
mode: str = "style",
|
||||
weight: float = 0.8,
|
||||
negative: str = "",
|
||||
preset: str | None = None,
|
||||
weight_type: str | None = None,
|
||||
start_at: float = 0.0,
|
||||
end_at: float = 1.0,
|
||||
weight_faceidv2: float = 1.0,
|
||||
lora_strength: float = 0.6,
|
||||
combine_embeds: str = "concat",
|
||||
embeds_scaling: str = "V only",
|
||||
provider: str = "CPU",
|
||||
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 = "ipadapter",
|
||||
) -> dict:
|
||||
"""Construye un workflow txt2img condicionado por una imagen de referencia.
|
||||
|
||||
Args:
|
||||
prompt: prompt positivo (texto del resultado deseado).
|
||||
ref_image: nombre del archivo de imagen de referencia en el directorio
|
||||
input/ del servidor ComfyUI (lo carga un nodo LoadImage). En faceid
|
||||
debe contener una cara nitida; en style es la imagen de estilo.
|
||||
base_checkpoint: checkpoint SD1.5/SDXL (debe casar con los modelos
|
||||
IPAdapter: usa modelos SD1.5 con checkpoints SD1.5). keyword-only.
|
||||
mode: 'style' (transfiere estilo/composicion) o 'faceid' (rostro
|
||||
consistente via insightface + FaceID). keyword-only.
|
||||
weight: peso de la influencia IPAdapter (0..1+). 0.8 es un buen punto de
|
||||
partida; sube para mas parecido, baja para mas libertad del prompt.
|
||||
negative: prompt negativo.
|
||||
preset: preset del UnifiedLoader. Si None usa el default del modo
|
||||
('STANDARD (medium strength)' para style, 'FACEID PLUS V2' para faceid).
|
||||
weight_type: tipo de ponderacion del nodo IPAdapter/FaceID. Si None usa el
|
||||
default del modo ('standard' para style, 'linear' para faceid).
|
||||
start_at: fraccion del sampling donde empieza a aplicar IPAdapter (0..1).
|
||||
end_at: fraccion del sampling donde deja de aplicar (0..1).
|
||||
weight_faceidv2: peso del embedding FaceID v2 (solo mode='faceid').
|
||||
lora_strength: fuerza de la LoRA FaceID que carga el UnifiedLoaderFaceID
|
||||
(solo mode='faceid').
|
||||
combine_embeds: como combinar embeddings si hay varias caras
|
||||
('concat'|'add'|'subtract'|'average'|'norm average'). Solo faceid.
|
||||
embeds_scaling: escalado de embeddings ('V only'|'K+V'|...). Solo faceid.
|
||||
provider: backend de insightface ('CPU'|'CUDA'|...). CPU por defecto para
|
||||
no competir por VRAM con el modelo de difusion. Solo faceid.
|
||||
steps, cfg, width, height, seed, sampler_name, scheduler, filename_prefix:
|
||||
parametros de generacion que se pasan a comfyui_build_txt2img_workflow.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow, con la base
|
||||
txt2img + LoadImage + la rama IPAdapter del modo elegido, y el KSampler
|
||||
repuntado a la salida MODEL de esa rama.
|
||||
|
||||
Raises:
|
||||
ValueError: si mode no es 'style' ni 'faceid', si ref_image esta vacio, o
|
||||
si no se puede localizar el checkpoint/KSampler en la base.
|
||||
"""
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
if mode not in ("style", "faceid"):
|
||||
raise ValueError(
|
||||
f"comfyui_build_ipadapter_workflow: mode debe ser 'style' o 'faceid', no {mode!r}"
|
||||
)
|
||||
if not ref_image:
|
||||
raise ValueError("comfyui_build_ipadapter_workflow: ref_image no puede estar vacio")
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
base_checkpoint,
|
||||
prompt,
|
||||
negative,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
width=width,
|
||||
height=height,
|
||||
seed=seed,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
filename_prefix=filename_prefix,
|
||||
)
|
||||
|
||||
ckpt = next(
|
||||
(nid for nid, n in wf.items() if str(n.get("class_type", "")).startswith("CheckpointLoader")),
|
||||
None,
|
||||
)
|
||||
ksampler = next(
|
||||
(nid for nid, n in wf.items() if str(n.get("class_type", "")).endswith("KSampler")),
|
||||
None,
|
||||
)
|
||||
if ckpt is None or ksampler is None:
|
||||
raise ValueError(
|
||||
"comfyui_build_ipadapter_workflow: no se encontro CheckpointLoader/KSampler en la base"
|
||||
)
|
||||
|
||||
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||
base_id = (max(numeric) + 1) if numeric else len(wf) + 1
|
||||
load_id = str(base_id)
|
||||
loader_id = str(base_id + 1)
|
||||
apply_id = str(base_id + 2)
|
||||
|
||||
used_preset = preset if preset is not None else _DEFAULT_PRESET[mode]
|
||||
used_wtype = weight_type if weight_type is not None else _DEFAULT_WEIGHT_TYPE[mode]
|
||||
|
||||
# Carga la imagen de referencia (slot 0 = IMAGE).
|
||||
wf[load_id] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": ref_image},
|
||||
}
|
||||
|
||||
if mode == "style":
|
||||
wf[loader_id] = {
|
||||
"class_type": "IPAdapterUnifiedLoader",
|
||||
"inputs": {"model": [ckpt, 0], "preset": used_preset},
|
||||
}
|
||||
wf[apply_id] = {
|
||||
"class_type": "IPAdapter",
|
||||
"inputs": {
|
||||
"model": [loader_id, 0],
|
||||
"ipadapter": [loader_id, 1],
|
||||
"image": [load_id, 0],
|
||||
"weight": weight,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"weight_type": used_wtype,
|
||||
},
|
||||
}
|
||||
else: # faceid
|
||||
wf[loader_id] = {
|
||||
"class_type": "IPAdapterUnifiedLoaderFaceID",
|
||||
"inputs": {
|
||||
"model": [ckpt, 0],
|
||||
"preset": used_preset,
|
||||
"lora_strength": lora_strength,
|
||||
"provider": provider,
|
||||
},
|
||||
}
|
||||
wf[apply_id] = {
|
||||
"class_type": "IPAdapterFaceID",
|
||||
"inputs": {
|
||||
"model": [loader_id, 0],
|
||||
"ipadapter": [loader_id, 1],
|
||||
"image": [load_id, 0],
|
||||
"weight": weight,
|
||||
"weight_faceidv2": weight_faceidv2,
|
||||
"weight_type": used_wtype,
|
||||
"combine_embeds": combine_embeds,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"embeds_scaling": embeds_scaling,
|
||||
},
|
||||
}
|
||||
|
||||
# Repunta el KSampler para que tome el MODEL condicionado por IPAdapter.
|
||||
wf[ksampler]["inputs"]["model"] = [apply_id, 0]
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf_style = comfyui_build_ipadapter_workflow(
|
||||
"a fantasy castle on a hill, oil painting",
|
||||
"example.png",
|
||||
base_checkpoint="dreamshaper_8.safetensors",
|
||||
mode="style",
|
||||
weight=0.8,
|
||||
)
|
||||
wf_face = comfyui_build_ipadapter_workflow(
|
||||
"portrait of a knight in armor, cinematic",
|
||||
"showcase_char.png",
|
||||
base_checkpoint="dreamshaper_8.safetensors",
|
||||
mode="faceid",
|
||||
weight=0.9,
|
||||
)
|
||||
print(json.dumps({"style_nodes": list(wf_style), "faceid_nodes": list(wf_face)}, 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,89 @@
|
||||
---
|
||||
name: comfyui_build_skill_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict"
|
||||
description: "Compila una receta de skill ComfyUI (dict recipe.json del grupo comfyui-skill) a un workflow en API format listo para comfyui_submit_workflow. Despacha al builder base segun recipe['base_workflow'] (txt2img|flux|sdxl_refiner), sustituye {subject} y los trigger_words en el prompt_scaffold, encadena los loras (inject_lora) y aplica los blocks de post-proceso (facedetailer, hires_fix) en orden. Pura: solo compone builders puros del registry, sin red ni I/O. base_workflow desconocido o que requiere imagen -> SkillWorkflowError."
|
||||
tags: [comfyui, comfyui-skill, ml, workflow, stable-diffusion, skill]
|
||||
uses_functions:
|
||||
- comfyui_build_txt2img_workflow_py_ml
|
||||
- comfyui_build_flux_workflow_py_ml
|
||||
- comfyui_build_sdxl_refiner_workflow_py_ml
|
||||
- comfyui_inject_lora_py_ml
|
||||
- comfyui_build_facedetailer_workflow_py_ml
|
||||
- comfyui_inject_hires_fix_py_ml
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: recipe
|
||||
desc: "Dict de la receta (schema comfyui-skill). Campos usados: base_workflow (txt2img|flux|sdxl_refiner), checkpoint, loras [{name, strength_model, strength_clip}], params (steps/cfg/width/height/sampler_name/scheduler/...), prompt_scaffold (positive/negative con {subject} + trigger_words), blocks [{type, params}]."
|
||||
- name: subject
|
||||
desc: "El sujeto concreto que sustituye {subject} en el scaffold del prompt (ej. 'a woman with red hair')."
|
||||
- name: seed
|
||||
desc: "Semilla de generacion; se pasa al builder base y por defecto a cada bloque que la acepte. keyword-only."
|
||||
output: "dict en API format (nodos numerados con class_type + inputs) listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["golden: txt2img + 1 lora + facedetailer -> API format valido con LoraLoader + FaceDetailer y subject sustituido", "edge: sin loras ni blocks -> workflow base minimo (6 class_types)", "params seed/steps/cfg/width + trigger_words reflejados", "error: base_workflow desconocido -> SkillWorkflowError", "error: base que requiere imagen (img2img) -> SkillWorkflowError", "error: recipe no dict -> SkillWorkflowError"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_skill_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_skill_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_skill_workflow import build_skill_workflow
|
||||
|
||||
recipe = {
|
||||
"schema_version": 1,
|
||||
"slug": "portrait_cinematic_sdxl",
|
||||
"version": "1.0.0",
|
||||
"base_workflow": "txt2img",
|
||||
"checkpoint": "juggernaut_xl_v11.safetensors",
|
||||
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras", "width": 832, "height": 1216},
|
||||
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||
"negative": "blurry, lowres", "trigger_words": []},
|
||||
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
|
||||
}
|
||||
|
||||
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
|
||||
# wf tiene CheckpointLoaderSimple + KSampler + LoraLoader + FaceDetailer + SaveImage.
|
||||
# Pasalo a comfyui_submit_workflow para encolarlo.
|
||||
```
|
||||
|
||||
O lanzable directo con `./fn run comfyui_build_skill_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando tengas una **receta de skill** (de `comfyui_load_skill`) y quieras convertirla en un
|
||||
workflow concreto para un `subject` dado, sin montar el grafo a mano. Es el paso "receta →
|
||||
workflow" del flujo del grupo `comfyui-skill`: `load_skill` → `build_skill_workflow` →
|
||||
`submit_workflow` → `wait_result`.
|
||||
- Cuando reutilices una configuración probada (checkpoint + LoRAs + params + post-proceso)
|
||||
cambiando solo el sujeto y la semilla.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pura, NO valida contra el servidor**: igual que los builders hermanos, no comprueba que el
|
||||
checkpoint/LoRA/modelo de upscale existan en ComfyUI. Si faltan, el servidor rechaza el
|
||||
workflow con HTTP 400 al enviarlo. Valida antes con `comfyui_validate_workflow` si hace falta.
|
||||
- **Solo bases de texto**: soporta `base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`}. Las
|
||||
bases que requieren una imagen de entrada (`img2img`, `inpaint`, `controlnet`) lanzan
|
||||
`SkillWorkflowError` — `build_skill_workflow` arranca de un `subject` de texto, no de una imagen.
|
||||
- **`sdxl_refiner` requiere `params['refiner_ckpt']`**; sin él lanza `SkillWorkflowError`.
|
||||
- **Flux ignora el negativo**: el builder de Flux usa un negativo vacío fijo y la guía va por
|
||||
`FluxGuidance`; en Flux el campo `checkpoint` de la receta se mapea a `unet`.
|
||||
- **El orden de los `blocks` importa**: se aplican secuencialmente sobre el dict. `facedetailer`
|
||||
toma la imagen del `VAEDecode`; `hires_fix` re-difunde y repunta el `SaveImage`. Encadénalos en
|
||||
el orden lógico (p.ej. hires_fix tras facedetailer).
|
||||
- **Excepción tipada**: todos los errores de compilación son `SkillWorkflowError` (subclase de
|
||||
`ValueError`), exportada por el módulo.
|
||||
@@ -0,0 +1,213 @@
|
||||
"""comfyui_build_skill_workflow — compila una receta de *skill* a un workflow ComfyUI.
|
||||
|
||||
Una **skill** es una receta versionada (el dict de `recipe.json` del grupo `comfyui-skill`) que
|
||||
fija checkpoint, LoRAs, parametros de sampling, scaffold de prompt y bloques de post-proceso.
|
||||
Esta funcion PURA la compila a un dict de workflow en "API format" listo para
|
||||
`comfyui_submit_workflow`, componiendo los builders del registry segun `recipe['base_workflow']`.
|
||||
|
||||
Despacho de `base_workflow` (los que se construyen solo a partir de un `subject` de texto):
|
||||
|
||||
txt2img -> comfyui_build_txt2img_workflow
|
||||
flux -> comfyui_build_flux_workflow
|
||||
sdxl_refiner -> comfyui_build_sdxl_refiner_workflow
|
||||
|
||||
Bloques de post-proceso (`recipe['blocks']`, aplicados en orden sobre el dict resultante):
|
||||
|
||||
facedetailer -> comfyui_build_facedetailer_workflow (encadena sobre el dict)
|
||||
hires_fix -> comfyui_inject_hires_fix (encadena sobre el dict)
|
||||
|
||||
Pasos: scaffold de prompt ({subject} + trigger_words) -> builder base -> LoRAs (inject_lora) ->
|
||||
bloques (en orden). Los `base_workflow` que necesitan una imagen de entrada (img2img, inpaint,
|
||||
controlnet) NO se soportan aqui — `build_skill_workflow` arranca de texto.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Solo compone builders puros del registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class SkillWorkflowError(ValueError):
|
||||
"""Error tipado al compilar una receta de skill (base_workflow desconocido,
|
||||
recipe invalida, bloque no soportado, dependencia ausente)."""
|
||||
|
||||
|
||||
_IMAGE_INPUT_BASES = {"img2img", "inpaint", "controlnet"}
|
||||
|
||||
|
||||
def _pick(params: dict, keys) -> dict:
|
||||
"""Subconjunto de `params` con solo las claves presentes en `keys`."""
|
||||
return {k: params[k] for k in keys if k in params}
|
||||
|
||||
|
||||
def _scaffold_prompts(recipe: dict, subject: str) -> tuple[str, str]:
|
||||
"""Sustituye `{subject}` y antepone los trigger_words en el scaffold de prompt.
|
||||
|
||||
Devuelve (positive, negative). Si no hay `prompt_scaffold`, usa el subject como
|
||||
positivo y "" como negativo.
|
||||
"""
|
||||
scaffold = recipe.get("prompt_scaffold") or {}
|
||||
positive = str(scaffold.get("positive", "") or "")
|
||||
negative = str(scaffold.get("negative", "") or "")
|
||||
if "{subject}" in positive:
|
||||
positive = positive.replace("{subject}", subject)
|
||||
elif not positive:
|
||||
positive = subject
|
||||
else:
|
||||
# scaffold sin placeholder: el subject se antepone para no perderlo.
|
||||
positive = f"{subject}, {positive}"
|
||||
negative = negative.replace("{subject}", subject)
|
||||
|
||||
triggers = scaffold.get("trigger_words") or []
|
||||
if triggers:
|
||||
positive = ", ".join(list(triggers) + [positive]) if positive else ", ".join(triggers)
|
||||
return positive, negative
|
||||
|
||||
|
||||
def _build_base(recipe: dict, positive: str, negative: str, seed: int) -> dict:
|
||||
"""Despacha al builder base del registry segun `recipe['base_workflow']`."""
|
||||
base = recipe.get("base_workflow")
|
||||
params = dict(recipe.get("params") or {})
|
||||
ckpt = recipe.get("checkpoint", "")
|
||||
|
||||
if base == "txt2img":
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
if not ckpt:
|
||||
raise SkillWorkflowError("base_workflow txt2img requiere recipe['checkpoint']")
|
||||
kw = _pick(params, ("steps", "cfg", "width", "height", "sampler_name", "scheduler"))
|
||||
return comfyui_build_txt2img_workflow(ckpt, positive, negative, seed=seed, **kw)
|
||||
|
||||
if base == "flux":
|
||||
from ml.comfyui_build_flux_workflow import comfyui_build_flux_workflow
|
||||
kw = _pick(params, ("clip_l", "t5xxl", "vae", "width", "height", "steps",
|
||||
"guidance", "weight_dtype", "sampler_name", "scheduler"))
|
||||
# En Flux el "checkpoint" de la receta es el modelo de difusion (unet).
|
||||
if ckpt:
|
||||
kw.setdefault("unet", ckpt)
|
||||
return comfyui_build_flux_workflow(positive, seed=seed, **kw)
|
||||
|
||||
if base == "sdxl_refiner":
|
||||
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
|
||||
refiner = params.get("refiner_ckpt")
|
||||
if not ckpt or not refiner:
|
||||
raise SkillWorkflowError(
|
||||
"base_workflow sdxl_refiner requiere recipe['checkpoint'] (base) y "
|
||||
"recipe['params']['refiner_ckpt']")
|
||||
kw = _pick(params, ("base_steps", "refiner_steps", "cfg", "width", "height"))
|
||||
return comfyui_build_sdxl_refiner_workflow(ckpt, refiner, positive, negative, seed=seed, **kw)
|
||||
|
||||
if base in _IMAGE_INPUT_BASES:
|
||||
raise SkillWorkflowError(
|
||||
f"base_workflow {base!r} requiere una imagen de entrada; build_skill_workflow "
|
||||
"compila a partir de un subject de texto. Construye ese workflow aparte.")
|
||||
|
||||
raise SkillWorkflowError(
|
||||
f"base_workflow desconocido: {base!r}. Soportados: txt2img, flux, sdxl_refiner.")
|
||||
|
||||
|
||||
def _apply_loras(workflow: dict, loras) -> dict:
|
||||
"""Encadena los LoRAs de la receta via comfyui_inject_lora (en orden)."""
|
||||
if not loras:
|
||||
return workflow
|
||||
from ml.comfyui_inject_lora import comfyui_inject_lora
|
||||
wf = workflow
|
||||
for lora in loras:
|
||||
name = lora.get("name")
|
||||
if not name:
|
||||
raise SkillWorkflowError("cada lora de la receta necesita 'name'")
|
||||
wf = comfyui_inject_lora(
|
||||
wf,
|
||||
name,
|
||||
strength_model=lora.get("strength_model", 1.0),
|
||||
strength_clip=lora.get("strength_clip", 1.0),
|
||||
)
|
||||
return wf
|
||||
|
||||
|
||||
def _apply_block(workflow: dict, block: dict, recipe: dict, positive: str,
|
||||
negative: str, seed: int) -> dict:
|
||||
"""Aplica un bloque de post-proceso sobre el workflow (facedetailer | hires_fix)."""
|
||||
btype = block.get("type")
|
||||
bparams = dict(block.get("params") or {})
|
||||
|
||||
if btype == "facedetailer":
|
||||
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow
|
||||
bparams.setdefault("seed", seed)
|
||||
return comfyui_build_facedetailer_workflow(
|
||||
workflow, recipe.get("checkpoint", ""), positive, negative, **bparams)
|
||||
|
||||
if btype == "hires_fix":
|
||||
try:
|
||||
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix
|
||||
except ImportError as exc:
|
||||
raise SkillWorkflowError(
|
||||
"bloque hires_fix requiere comfyui_inject_hires_fix_py_ml (no disponible)") from exc
|
||||
bparams.setdefault("seed", seed)
|
||||
return comfyui_inject_hires_fix(workflow, **bparams)
|
||||
|
||||
raise SkillWorkflowError(
|
||||
f"bloque de tipo desconocido: {btype!r}. Soportados: facedetailer, hires_fix.")
|
||||
|
||||
|
||||
def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict:
|
||||
"""Compila una receta de skill a un workflow ComfyUI en API format.
|
||||
|
||||
Args:
|
||||
recipe: dict de la receta (schema `comfyui-skill`). Campos usados:
|
||||
`base_workflow` (txt2img|flux|sdxl_refiner), `checkpoint`, `loras`,
|
||||
`params`, `prompt_scaffold` (`positive`/`negative` con `{subject}` +
|
||||
`trigger_words`), `blocks` (lista de `{type, params}`).
|
||||
subject: el sujeto concreto que sustituye `{subject}` en el scaffold del
|
||||
prompt (p.ej. "a woman with red hair").
|
||||
seed: semilla de generacion; se pasa al builder base y por defecto a cada
|
||||
bloque que la acepte. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para `comfyui_submit_workflow`.
|
||||
|
||||
Raises:
|
||||
SkillWorkflowError: si `base_workflow` es desconocido o necesita imagen, si
|
||||
la receta no es un dict valido, si falta un checkpoint requerido, o si un
|
||||
bloque es de tipo no soportado / su dependencia no esta disponible.
|
||||
"""
|
||||
if not isinstance(recipe, dict):
|
||||
raise SkillWorkflowError(f"recipe debe ser dict, no {type(recipe).__name__}")
|
||||
|
||||
positive, negative = _scaffold_prompts(recipe, subject)
|
||||
workflow = _build_base(recipe, positive, negative, seed)
|
||||
workflow = _apply_loras(workflow, recipe.get("loras"))
|
||||
|
||||
for block in (recipe.get("blocks") or []):
|
||||
if not isinstance(block, dict):
|
||||
raise SkillWorkflowError(f"cada block debe ser dict, no {type(block).__name__}")
|
||||
workflow = _apply_block(workflow, block, recipe, positive, negative, seed)
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
# Alias con el nombre completo del ID para descubrimiento por convencion.
|
||||
comfyui_build_skill_workflow = build_skill_workflow
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
demo = {
|
||||
"schema_version": 1,
|
||||
"slug": "portrait_demo",
|
||||
"version": "1.0.0",
|
||||
"base_workflow": "txt2img",
|
||||
"checkpoint": "juggernaut_xl_v11.safetensors",
|
||||
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
|
||||
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras", "width": 832, "height": 1216},
|
||||
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
|
||||
"negative": "blurry, lowres", "trigger_words": []},
|
||||
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
|
||||
}
|
||||
wf = build_skill_workflow(demo, "a woman with red hair", 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,90 @@
|
||||
---
|
||||
name: comfyui_bump_skill_version
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_bump_skill_version(slug: str, change: str, *, score_before: float, score_after: float, judge_run_id: str = None, recipe_patch: dict = None, force: bool = False, bump: str = \"minor\", library_dir: str = None, timestamp: float = None) -> dict"
|
||||
description: "Promueve una version nueva de una skill ComfyUI SOLO si el score sube (gate objetivo del bucle de mejora, grupo comfyui-skill). Si score_after <= score_before y no force, rechaza con ok=False sin tocar nada. Si pasa: snapshot pre-mutacion versions/vN.json, aplica recipe_patch (deep-merge) a recipe.json, sube el semver (minor default), y appende a growth_log.jsonl una linea {version,date,change,score_before,score_after,judge_run_id,diff}. library_dir default ~/ComfyUI/skills_library. Slug inexistente -> ok=False. No usa datetime.now (deriva la fecha del timestamp recibido o time.time). Impura: disco. Nunca lanza."
|
||||
error_type: error_py_core
|
||||
tags: [comfyui, comfyui-skill, ml, skill, versioning, growth]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
imports: []
|
||||
params:
|
||||
- name: slug
|
||||
desc: "Slug de la skill (su carpeta en la libreria)."
|
||||
- name: change
|
||||
desc: "Descripcion de una linea del cambio que motiva el bump (va al growth_log)."
|
||||
- name: score_before
|
||||
desc: "Score 0-10 de la version actual (del panel comfyui-judge). keyword-only."
|
||||
- name: score_after
|
||||
desc: "Score 0-10 de la variante candidata. Debe ser > score_before salvo force=True. keyword-only."
|
||||
- name: judge_run_id
|
||||
desc: "Identificador de la corrida del juez que justifica el bump (evidencia trazable). keyword-only."
|
||||
- name: recipe_patch
|
||||
desc: "Dict con los cambios a aplicar sobre la receta (deep-merge). Ej. {'params': {'steps': 32}}. keyword-only."
|
||||
- name: force
|
||||
desc: "Si True, salta el gate y promueve aunque el score no mejore. keyword-only."
|
||||
- name: bump
|
||||
desc: "Parte del semver a subir: 'minor' (default), 'major' o 'patch'. keyword-only."
|
||||
- name: library_dir
|
||||
desc: "Raiz de la libreria. Default ~/ComfyUI/skills_library. keyword-only."
|
||||
- name: timestamp
|
||||
desc: "Epoch en segundos para la fecha del growth_log; None = time.time(). keyword-only."
|
||||
output: "dict {ok, slug, old_version, new_version, snapshot_file, growth_entry, recipe_path, error}. ok=False con error si el gate rechaza el bump, si la skill no existe, o si falla la escritura; nunca lanza."
|
||||
tested: true
|
||||
tests: [test_semver_helper, test_deep_merge_no_pisa_otras_claves, test_golden_promueve_cuando_score_sube, test_edge_major_y_patch, test_error_gate_bloquea_si_no_mejora, test_error_force_salta_gate, test_error_skill_inexistente]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_bump_skill_version.py"
|
||||
file_path: "python/functions/ml/comfyui_bump_skill_version.py"
|
||||
---
|
||||
|
||||
# comfyui_bump_skill_version
|
||||
|
||||
Pieza de cierre del bucle de mejora del grupo [`comfyui-skill`](../../../docs/capabilities/comfyui-skill.md):
|
||||
una skill genera, el panel [`comfyui-judge`](../../../docs/capabilities/comfyui-judge.md) la
|
||||
puntúa, y esta función promueve una versión nueva **solo si el score sube**. El juez decide, no
|
||||
el humano. Es el "crecimiento por composición" del issue 0087 aplicado a la generación de imágenes.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_bump_skill_version import comfyui_bump_skill_version
|
||||
|
||||
# La variante con steps=32 puntuó 7.4 vs 6.5 de la versión vigente → se promueve.
|
||||
res = comfyui_bump_skill_version(
|
||||
"portrait_cinematic_sd15", "subir steps 28→32 (mejor detalle facial)",
|
||||
score_before=6.5, score_after=7.4,
|
||||
judge_run_id="judge_abc123", recipe_patch={"params": {"steps": 32}},
|
||||
)
|
||||
print(res["old_version"], "→", res["new_version"]) # 1.0.0 → 1.1.0
|
||||
# recipe.json ahora en 1.1.0 con steps=32; versions/vN.json conserva la 1.0.0;
|
||||
# growth_log.jsonl tiene una línea con score_before/after + judge_run_id.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras juzgar una variante de una skill: si el `score` del panel supera al de la versión vigente,
|
||||
llama a esta función para **promover** la mejora (snapshot + semver + growth_log). Si el score no
|
||||
sube, NO la llames (o se rechaza por el gate) — esa es justo la garantía del bucle. Pásale el
|
||||
`judge_run_id` de `comfyui_generate_with_skill_oneshot` como evidencia trazable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Gate duro**: `score_after <= score_before` sin `force=True` devuelve `{ok:False}` y NO toca
|
||||
nada (ni snapshot, ni growth_log, ni versión). Es el comportamiento deseado, no un error.
|
||||
- **`force=True`** salta el gate (promueve aunque empeore) y marca `growth_entry.forced=True`.
|
||||
Úsalo solo para correcciones manuales, no en el bucle automático.
|
||||
- **`recipe_patch` es deep-merge**: dicts anidados (`params`) se fusionan; listas (`loras`,
|
||||
`blocks`) se reemplazan enteras (no se concatenan).
|
||||
- **Snapshot pre-mutación**: `versions/vN.json` guarda la receta ANTES del patch; el `recipe.json`
|
||||
queda con la versión nueva. Recupera la vieja con `comfyui_load_skill(slug, version=N)`.
|
||||
- **Fecha sin `datetime.now()`**: se deriva de `timestamp` o `time.time()` vía `time.strftime`
|
||||
(compatible con entornos que prohíben `datetime.now()`).
|
||||
- **No genera ni juzga**: solo promueve la receta. Generar + puntuar es trabajo de
|
||||
`comfyui_generate_with_skill_oneshot` + `comfyui_judge_image`.
|
||||
@@ -0,0 +1,224 @@
|
||||
"""comfyui_bump_skill_version — promueve una nueva versión de una *skill* ComfyUI.
|
||||
|
||||
Cierra el bucle de mejora del grupo `comfyui-skill`: una skill genera, el panel
|
||||
`comfyui-judge` la puntúa y, **solo si el score sube**, esta función promociona una
|
||||
versión nueva de la receta. Es la pieza de "crecimiento por composición" del issue 0087
|
||||
aplicada a la generación de imágenes — el catálogo de recetas crece registrando mejoras
|
||||
medibles, no inflando recetas a ciegas.
|
||||
|
||||
Qué hace, en orden:
|
||||
|
||||
1. **Gate objetivo**: si ``score_after <= score_before`` y no se pasa ``force=True``,
|
||||
rechaza con ``{ok: False}`` sin tocar nada. El juez (no el humano) decide.
|
||||
2. **Snapshot pre-mutación**: escribe ``versions/vN.json`` con la receta ACTUAL antes de
|
||||
cambiarla (backup recuperable con ``comfyui_load_skill(slug, version=N)``).
|
||||
3. **Aplica ``recipe_patch``** (deep-merge) sobre ``recipe.json``.
|
||||
4. **Sube el semver** de la receta (``minor`` por defecto: 1.0.0 → 1.1.0).
|
||||
5. **Append a ``growth_log.jsonl``**: una línea
|
||||
``{version, date, change, score_before, score_after, judge_run_id, diff}``.
|
||||
|
||||
`library_dir` por defecto ``~/ComfyUI/skills_library``. Slug inexistente → ``{ok: False}``.
|
||||
|
||||
Impura: lee y escribe archivos en disco. Sin red. No usa ``datetime.now()`` (prohibido en
|
||||
algunos entornos) — la fecha se deriva del ``timestamp`` recibido o de ``time.time()``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
|
||||
|
||||
|
||||
def _lib_dir(library_dir):
|
||||
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
|
||||
|
||||
|
||||
def _bump_semver(version: str, part: str) -> str:
|
||||
"""Sube un semver ``X.Y.Z`` por ``major``/``minor``/``patch``.
|
||||
|
||||
Tolerante a versiones mal formadas: si ``version`` no parsea como tres enteros,
|
||||
parte de ``0.0.0`` antes de aplicar el incremento.
|
||||
"""
|
||||
try:
|
||||
nums = (list(map(int, str(version).split("."))) + [0, 0, 0])[:3]
|
||||
major, minor, patch = nums
|
||||
except (ValueError, TypeError):
|
||||
major, minor, patch = 0, 0, 0
|
||||
if part == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
if part == "patch":
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
# minor (default)
|
||||
return f"{major}.{minor + 1}.0"
|
||||
|
||||
|
||||
def _deep_merge(base: dict, patch: dict) -> dict:
|
||||
"""Merge recursivo de ``patch`` sobre ``base`` sin mutar los originales.
|
||||
|
||||
Las claves cuyo valor es dict en ambos se fusionan en profundidad; cualquier otro
|
||||
tipo (incluida una lista, p.ej. ``loras``/``blocks``) se reemplaza entero.
|
||||
"""
|
||||
out = dict(base)
|
||||
for key, val in (patch or {}).items():
|
||||
if isinstance(val, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = _deep_merge(out[key], val)
|
||||
else:
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
def _next_version_index(versions_dir: str) -> int:
|
||||
"""Siguiente N para ``versions/vN.json`` (1 + cuántos snapshots ya hay)."""
|
||||
try:
|
||||
existing = [f for f in os.listdir(versions_dir)
|
||||
if f.startswith("v") and f.endswith(".json")]
|
||||
except OSError:
|
||||
existing = []
|
||||
return len(existing) + 1
|
||||
|
||||
|
||||
def comfyui_bump_skill_version(
|
||||
slug: str,
|
||||
change: str,
|
||||
*,
|
||||
score_before: float,
|
||||
score_after: float,
|
||||
judge_run_id: str = None,
|
||||
recipe_patch: dict = None,
|
||||
force: bool = False,
|
||||
bump: str = "minor",
|
||||
library_dir: str = None,
|
||||
timestamp: float = None,
|
||||
) -> dict:
|
||||
"""Promueve una versión nueva de una skill si el score mejora (gate objetivo).
|
||||
|
||||
Args:
|
||||
slug: slug de la skill (su carpeta en la librería).
|
||||
change: descripción de una línea del cambio que motiva el bump (va al growth_log).
|
||||
score_before: score de la versión actual (del panel-juez). keyword-only.
|
||||
score_after: score de la variante candidata. Debe ser ``> score_before`` salvo
|
||||
``force=True``. keyword-only.
|
||||
judge_run_id: identificador de la corrida del juez que justifica el bump
|
||||
(evidencia trazable). keyword-only.
|
||||
recipe_patch: dict con los cambios a aplicar sobre la receta (deep-merge). Por
|
||||
ejemplo ``{"params": {"steps": 32}}``. keyword-only.
|
||||
force: si True, salta el gate y promueve aunque el score no mejore. keyword-only.
|
||||
bump: parte del semver a subir: ``minor`` (default), ``major`` o ``patch``.
|
||||
keyword-only.
|
||||
library_dir: raíz de la librería. Default ``~/ComfyUI/skills_library``. keyword-only.
|
||||
timestamp: epoch en segundos para la fecha del growth_log; None = ``time.time()``.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict ``{ok, slug, old_version, new_version, snapshot_file, growth_entry,
|
||||
recipe_path, error}``. ``ok=False`` con ``error`` si el gate rechaza el bump, si
|
||||
la skill no existe, o si falla la escritura; nunca lanza.
|
||||
"""
|
||||
if not slug or not isinstance(slug, str):
|
||||
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
|
||||
"error": "slug requerido (string no vacío)"}
|
||||
|
||||
# 1. Gate objetivo: el juez decide, no el humano.
|
||||
try:
|
||||
sb = float(score_before)
|
||||
sa = float(score_after)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
|
||||
"error": f"score_before/score_after deben ser numéricos "
|
||||
f"(recibido {score_before!r}, {score_after!r})"}
|
||||
if sa <= sb and not force:
|
||||
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": "",
|
||||
"error": f"gate: score_after ({sa}) no supera score_before ({sb}); "
|
||||
f"no se promueve (usa force=True para forzar)"}
|
||||
|
||||
lib = _lib_dir(library_dir)
|
||||
skill_dir = os.path.join(lib, slug)
|
||||
recipe_path = os.path.join(skill_dir, "recipe.json")
|
||||
versions_dir = os.path.join(skill_dir, "versions")
|
||||
|
||||
if not os.path.isfile(recipe_path):
|
||||
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
|
||||
"error": f"skill no encontrada: {slug!r} (sin recipe.json en {skill_dir})"}
|
||||
|
||||
try:
|
||||
with open(recipe_path, encoding="utf-8") as fh:
|
||||
recipe = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
return {"ok": False, "slug": slug, "old_version": "", "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
|
||||
"error": f"no se pudo leer la receta: {exc}"}
|
||||
|
||||
old_version = recipe.get("version", "0.0.0")
|
||||
new_version = _bump_semver(old_version, bump)
|
||||
ts = float(timestamp) if timestamp is not None else time.time()
|
||||
date = time.strftime("%Y-%m-%d", time.gmtime(ts))
|
||||
|
||||
try:
|
||||
os.makedirs(versions_dir, exist_ok=True)
|
||||
# 2. Snapshot pre-mutación: preserva la receta actual antes de cambiarla.
|
||||
n = _next_version_index(versions_dir)
|
||||
snapshot_file = os.path.join(versions_dir, f"v{n}.json")
|
||||
with open(snapshot_file, "w", encoding="utf-8") as fh:
|
||||
json.dump(recipe, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
# 3. Aplicar el patch (deep-merge) + 4. subir el semver.
|
||||
new_recipe = _deep_merge(recipe, recipe_patch or {})
|
||||
new_recipe["version"] = new_version
|
||||
with open(recipe_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(new_recipe, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
# 5. Bitácora append-only del crecimiento.
|
||||
growth_entry = {
|
||||
"version": new_version,
|
||||
"date": date,
|
||||
"ts": int(ts),
|
||||
"change": change,
|
||||
"score_before": sb,
|
||||
"score_after": sa,
|
||||
"judge_run_id": judge_run_id or "",
|
||||
"diff": recipe_patch or {},
|
||||
"forced": bool(force and sa <= sb),
|
||||
}
|
||||
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(growth_entry, ensure_ascii=False) + "\n")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "slug": slug, "old_version": old_version, "new_version": "",
|
||||
"snapshot_file": "", "growth_entry": None, "recipe_path": recipe_path,
|
||||
"error": f"fallo de escritura: {exc}"}
|
||||
|
||||
return {"ok": True, "slug": slug, "old_version": old_version,
|
||||
"new_version": new_version, "snapshot_file": snapshot_file,
|
||||
"growth_entry": growth_entry, "recipe_path": recipe_path, "error": ""}
|
||||
|
||||
|
||||
# Alias con el nombre completo del ID para descubrimiento por convención.
|
||||
bump_skill_version = comfyui_bump_skill_version
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# Demo offline contra una librería temporal.
|
||||
demo_lib = "/tmp/skills_bump_demo"
|
||||
sdir = os.path.join(demo_lib, "demo_skill")
|
||||
os.makedirs(os.path.join(sdir, "versions"), exist_ok=True)
|
||||
with open(os.path.join(sdir, "recipe.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
|
||||
"base_workflow": "txt2img", "params": {"steps": 28}}, f, indent=2)
|
||||
|
||||
# Gate bloquea cuando no mejora.
|
||||
blocked = comfyui_bump_skill_version("demo_skill", "subir steps", score_before=7.0,
|
||||
score_after=6.5, library_dir=demo_lib)
|
||||
print("gate bloquea:", blocked["ok"], "->", blocked["error"], file=sys.stderr)
|
||||
|
||||
# Promueve cuando mejora.
|
||||
ok = comfyui_bump_skill_version("demo_skill", "subir steps a 32", score_before=6.5,
|
||||
score_after=7.4, judge_run_id="judge_abc",
|
||||
recipe_patch={"params": {"steps": 32}},
|
||||
library_dir=demo_lib)
|
||||
print(json.dumps(ok, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: comfyui_compose_capabilities
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_compose_capabilities(base_workflow: dict, *, loras: list[dict] | None = None, controlnet: dict | None = None, ipadapter: dict | None = None, hires: dict | None = None, facedetailer: dict | None = None) -> dict"
|
||||
description: "Mezclador de capacidades ComfyUI: toma un workflow base en API format (skill o txt2img) y aplica EN ORDEN las capacidades activadas (cada arg None = desactivada), componiendo los inyectores/builders encadenables del registry: loras (inject_multi_lora) -> controlnet (inject_controlnet) -> ipadapter (inject_ipadapter) -> facedetailer (build_facedetailer_workflow) -> hires (inject_hires_fix), reconectando MODEL/CLIP/positive/IMAGE. Cada capacidad es opcional e independiente; sin ninguna devuelve el base intacto. Pura: no muta el dict de entrada."
|
||||
tags: [comfyui, comfyui-skill, ml, mixer, lora, controlnet, ipadapter, facedetailer, hires, workflow]
|
||||
uses_functions: [comfyui_inject_multi_lora_py_ml, comfyui_inject_controlnet_py_ml, comfyui_inject_ipadapter_py_ml, comfyui_build_facedetailer_workflow_py_ml, comfyui_inject_hires_fix_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: base_workflow
|
||||
desc: "dict en API format (salida de comfyui_build_skill_workflow o comfyui_build_txt2img_workflow). No se muta; se devuelve una copia."
|
||||
- name: loras
|
||||
desc: "Lista de dicts {name, strength_model?, strength_clip?} para inject_multi_lora. None o vacia = sin LoRAs. keyword-only."
|
||||
- name: controlnet
|
||||
desc: "Dict para inject_controlnet: {control_image (obligatoria), cn_name (obligatoria), strength?, positive_node?}. None = sin ControlNet. keyword-only."
|
||||
- name: ipadapter
|
||||
desc: "Dict para inject_ipadapter: {ref_image (obligatoria), mode ('style'|'faceid'), weight?, ...}. None = sin IPAdapter. keyword-only."
|
||||
- name: hires
|
||||
desc: "Dict de kwargs para inject_hires_fix (upscale_by, denoise, steps, cfg, seed, upscale_model, ...). {} = hires con defaults. None = sin hires. keyword-only."
|
||||
- name: facedetailer
|
||||
desc: "Dict de overrides para build_facedetailer_workflow. ckpt_name/positive/negative se detectan del workflow si faltan; resto = params del builder (denoise, steps, bbox_model, ...). {} = detect + defaults. None = sin facedetailer. keyword-only."
|
||||
output: "copia del base con las capacidades activadas encadenadas en orden (loras -> controlnet -> ipadapter -> facedetailer -> hires). Sin ninguna activada, copia del base intacta. Tras facedetailer deja un unico SaveImage (el del detailer)."
|
||||
tested: true
|
||||
tests: ["sin capacidades devuelve el base intacto (mismos nodos)", "solo loras encadena los LoraLoader", "loras + facedetailer: cadena de loras + FaceDetailer + un solo SaveImage", "ipadapter + lora: IPAdapter toma el MODEL del ultimo LoraLoader", "hires anade UltimateSDUpscale", "controlnet sin control_image propaga ValueError", "ipadapter sin ref_image propaga ValueError", "no muta el dict de entrada (pureza)", "api format valido en todas las combinaciones", "activar una capacidad cambia el conjunto de class_types"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_compose_capabilities.py"
|
||||
file_path: "python/functions/ml/comfyui_compose_capabilities.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_compose_capabilities import comfyui_compose_capabilities
|
||||
|
||||
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a hero, 3d render style")
|
||||
|
||||
# 3 capacidades a la vez: 2 LoRAs + FaceDetailer (activar/desactivar = cambiar args)
|
||||
mixed = comfyui_compose_capabilities(
|
||||
base,
|
||||
loras=[
|
||||
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
|
||||
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
|
||||
],
|
||||
facedetailer={"denoise": 0.45},
|
||||
# controlnet=..., ipadapter=..., hires=... -> None = desactivadas
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras **mezclar varias capacidades de generacion** (LoRAs + ControlNet +
|
||||
IPAdapter + FaceDetailer + hires) sobre un mismo workflow base y poder
|
||||
activar/desactivar cada una para iterar y mejorar. Es el "mixer" del grupo
|
||||
`comfyui-skill`: una sola funcion en vez de encadenar los inyectores a mano. La
|
||||
salida va directa a `comfyui_submit_workflow` (o usa el one-shot
|
||||
`comfyui_generate_mixed_oneshot` para submit + juicio).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura: no muta el `base_workflow` y NO valida que checkpoints/loras/modelos
|
||||
existan en el servidor. Las imagenes de control/referencia (ControlNet,
|
||||
IPAdapter) deben estar en el `input/` del servidor antes de submit.
|
||||
- **Orden fijo**: loras -> controlnet -> ipadapter -> facedetailer -> hires. El
|
||||
IPAdapter se aplica sobre el MODEL ya modificado por los LoRAs (orden correcto).
|
||||
- **hires + facedetailer NO encadenan** con las piezas actuales: ambos toman su
|
||||
imagen del VAEDecode del render base, asi que combinarlos deja a uno sin efecto
|
||||
sobre la salida final (con los dos activos, hires "gana" y facedetailer queda
|
||||
sin consumidor). Usa uno U otro por workflow. Es la limitacion documentada del
|
||||
mixer; el resto de combinaciones (loras+controlnet+ipadapter+uno de los dos
|
||||
post-procesos) encadenan limpio.
|
||||
- Cada capacidad apila coste de VRAM. En 8GB lowvram con SD1.5 entran ~2-3
|
||||
capacidades modestas (p.ej. 2 LoRAs + FaceDetailer a 512px). Apilar IPAdapter
|
||||
FaceID + ControlNet + hires + facedetailer a la vez puede dar OOM: baja
|
||||
resolucion o desactiva capacidades.
|
||||
- Errores de incompatibilidad (controlnet sin `control_image`, ipadapter sin
|
||||
`ref_image`, mode invalido) se propagan como `ValueError` del inyector, no
|
||||
petan en silencio.
|
||||
@@ -0,0 +1,203 @@
|
||||
"""comfyui_compose_capabilities — mezclador de capacidades sobre un workflow base.
|
||||
|
||||
Toma un workflow ComfyUI en API format (la base: salida de
|
||||
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow) y aplica EN ORDEN
|
||||
las capacidades que se activen, componiendo los inyectores/builders ENCADENABLES
|
||||
del registry. Cada capacidad es un argumento keyword opcional: None (default) =
|
||||
desactivada. Asi el mismo dict base se mezcla a la carta y se puede ir mejorando
|
||||
(activar/desactivar una capacidad cambia el grafo resultante).
|
||||
|
||||
Orden de aplicacion (de mas cerca del checkpoint a la salida):
|
||||
|
||||
1. loras -> comfyui_inject_multi_lora (cadena MODEL/CLIP)
|
||||
2. controlnet -> comfyui_inject_controlnet (re-condiciona KSampler.positive)
|
||||
3. ipadapter -> comfyui_inject_ipadapter (re-condiciona KSampler.model, tras loras)
|
||||
4. facedetailer -> comfyui_build_facedetailer_workflow (regenera caras del VAEDecode)
|
||||
5. hires -> comfyui_inject_hires_fix (UltimateSDUpscale tras el VAEDecode)
|
||||
|
||||
Cada capacidad es independiente: se puede activar cualquier subconjunto. Sin
|
||||
ninguna activada devuelve una copia del base intacta.
|
||||
|
||||
Funcion PURA: sin red, sin I/O. No muta el dict de entrada (copia profunda). Solo
|
||||
compone funciones puras del registry.
|
||||
|
||||
Limitacion conocida (piezas actuales): hires y facedetailer NO encadenan entre
|
||||
si. Ambos toman su imagen del VAEDecode original del render; combinarlos deja a
|
||||
uno de los dos sin efecto sobre la salida final. Usa uno U otro por workflow, o
|
||||
encadenalos manualmente fuera del mixer. Ver el .md (## Gotchas).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow # noqa: E402
|
||||
from ml.comfyui_inject_controlnet import comfyui_inject_controlnet # noqa: E402
|
||||
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix # noqa: E402
|
||||
from ml.comfyui_inject_ipadapter import comfyui_inject_ipadapter # noqa: E402
|
||||
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora # noqa: E402
|
||||
|
||||
|
||||
def _is_link(v) -> bool:
|
||||
"""True si v es una conexion ComfyUI [node_id(str), output_index(int)]."""
|
||||
return (
|
||||
isinstance(v, list)
|
||||
and len(v) == 2
|
||||
and isinstance(v[0], str)
|
||||
and isinstance(v[1], int)
|
||||
)
|
||||
|
||||
|
||||
def _detect_checkpoint(wf: dict) -> str:
|
||||
"""Nombre del checkpoint del primer CheckpointLoaderSimple, o '' si no hay."""
|
||||
for node in wf.values():
|
||||
if node.get("class_type") == "CheckpointLoaderSimple":
|
||||
return str(node.get("inputs", {}).get("ckpt_name", "")) or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_prompts(wf: dict) -> tuple[str, str]:
|
||||
"""Texto (positivo, negativo) de los dos primeros CLIPTextEncode del workflow.
|
||||
|
||||
En los builders del registry el positivo se inserta antes que el negativo, asi
|
||||
que el primer CLIPTextEncode es el positivo y el segundo el negativo.
|
||||
"""
|
||||
texts = [
|
||||
str(n.get("inputs", {}).get("text", ""))
|
||||
for n in wf.values()
|
||||
if n.get("class_type") == "CLIPTextEncode"
|
||||
]
|
||||
positive = texts[0] if texts else ""
|
||||
negative = texts[1] if len(texts) > 1 else ""
|
||||
return positive, negative
|
||||
|
||||
|
||||
def _prune_redundant_saveimages(wf: dict, keep_source_class: str) -> None:
|
||||
"""Deja un unico SaveImage: el alimentado por un nodo `keep_source_class`.
|
||||
|
||||
Tras encadenar facedetailer queda el SaveImage del render base (que ya no es
|
||||
la salida final) ademas del SaveImage del detailer. Se borra el primero para
|
||||
que el workflow tenga una sola imagen de salida (la procesada). Muta `wf` in
|
||||
situ (el caller ya trabaja sobre una copia). No-op si hay <=1 SaveImage o si
|
||||
no se encuentra el SaveImage alimentado por `keep_source_class`.
|
||||
"""
|
||||
saves = [
|
||||
(nid, n) for nid, n in wf.items() if n.get("class_type") == "SaveImage"
|
||||
]
|
||||
if len(saves) <= 1:
|
||||
return
|
||||
keep = None
|
||||
for nid, node in saves:
|
||||
src = node.get("inputs", {}).get("images")
|
||||
if _is_link(src) and wf.get(src[0], {}).get("class_type") == keep_source_class:
|
||||
keep = nid
|
||||
break
|
||||
if keep is None:
|
||||
return
|
||||
for nid, _ in saves:
|
||||
if nid != keep:
|
||||
del wf[nid]
|
||||
|
||||
|
||||
def comfyui_compose_capabilities(
|
||||
base_workflow: dict,
|
||||
*,
|
||||
loras: list[dict] | None = None,
|
||||
controlnet: dict | None = None,
|
||||
ipadapter: dict | None = None,
|
||||
hires: dict | None = None,
|
||||
facedetailer: dict | None = None,
|
||||
) -> dict:
|
||||
"""Aplica en orden las capacidades activadas sobre un workflow base.
|
||||
|
||||
Args:
|
||||
base_workflow: dict en API format (salida de
|
||||
comfyui_build_skill_workflow o comfyui_build_txt2img_workflow). No se
|
||||
muta; se devuelve una copia.
|
||||
loras: lista de dicts {name, strength_model?, strength_clip?} para
|
||||
comfyui_inject_multi_lora. None o lista vacia = sin LoRAs. keyword-only.
|
||||
controlnet: dict para comfyui_inject_controlnet. Claves: control_image
|
||||
(str, obligatoria), cn_name (str, obligatoria), strength (float),
|
||||
positive_node (str). None = sin ControlNet. keyword-only.
|
||||
ipadapter: dict para comfyui_inject_ipadapter. Claves: ref_image (str,
|
||||
obligatoria), mode ('style'|'faceid'), weight (float) y demas
|
||||
keyword-only del inyector. None = sin IPAdapter. keyword-only.
|
||||
hires: dict de kwargs para comfyui_inject_hires_fix (upscale_by, denoise,
|
||||
steps, cfg, seed, upscale_model, ...). {} = hires con defaults. None =
|
||||
sin hires. keyword-only.
|
||||
facedetailer: dict de overrides para comfyui_build_facedetailer_workflow.
|
||||
Claves opcionales: ckpt_name (str; si falta se detecta del workflow),
|
||||
positive / negative (str; si faltan se detectan de los CLIPTextEncode),
|
||||
y demas params del builder (denoise, steps, cfg, seed, bbox_model, ...).
|
||||
{} = facedetailer con detect + defaults. None = sin facedetailer.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
copia del base con las capacidades activadas encadenadas en orden. Si no
|
||||
se activa ninguna, una copia del base intacta.
|
||||
|
||||
Raises:
|
||||
ValueError: si una capacidad activada es incompatible (p.ej. controlnet
|
||||
sin control_image, ipadapter sin ref_image): se propaga el ValueError
|
||||
del inyector correspondiente con el contexto del fallo.
|
||||
"""
|
||||
wf = copy.deepcopy(base_workflow)
|
||||
|
||||
if loras:
|
||||
wf = comfyui_inject_multi_lora(wf, loras)
|
||||
|
||||
if controlnet is not None:
|
||||
cn = dict(controlnet)
|
||||
control_image = cn.pop("control_image", "")
|
||||
cn_name = cn.pop("cn_name", "")
|
||||
wf = comfyui_inject_controlnet(wf, control_image, cn_name, **cn)
|
||||
|
||||
if ipadapter is not None:
|
||||
ip = dict(ipadapter)
|
||||
ref_image = ip.pop("ref_image", "")
|
||||
wf = comfyui_inject_ipadapter(wf, ref_image, **ip)
|
||||
|
||||
if facedetailer is not None:
|
||||
fd = dict(facedetailer)
|
||||
ckpt_name = fd.pop("ckpt_name", None) or _detect_checkpoint(wf)
|
||||
det_pos, det_neg = _detect_prompts(wf)
|
||||
positive = fd.pop("positive", None)
|
||||
if positive is None:
|
||||
positive = det_pos
|
||||
negative = fd.pop("negative", None)
|
||||
if negative is None:
|
||||
negative = det_neg
|
||||
wf = comfyui_build_facedetailer_workflow(wf, ckpt_name, positive, negative, **fd)
|
||||
# facedetailer anade su propio SaveImage; el del render base ya no es la
|
||||
# salida final -> dejar solo el del detailer.
|
||||
_prune_redundant_saveimages(wf, "FaceDetailer")
|
||||
|
||||
if hires is not None:
|
||||
h = dict(hires) if isinstance(hires, dict) else {}
|
||||
wf = comfyui_inject_hires_fix(wf, **h)
|
||||
|
||||
return wf
|
||||
|
||||
|
||||
# Alias con el nombre completo del ID para descubrimiento por convencion.
|
||||
compose_capabilities = comfyui_compose_capabilities
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a hero, 3d render")
|
||||
mixed = comfyui_compose_capabilities(
|
||||
base,
|
||||
loras=[
|
||||
{"name": "3d_render_redmond_sd15.safetensors", "strength_model": 0.9},
|
||||
{"name": "detail_tweaker_sd15.safetensors", "strength_model": 0.5},
|
||||
],
|
||||
facedetailer={"denoise": 0.45},
|
||||
)
|
||||
print(json.dumps({"base_nodes": list(base), "mixed_nodes": list(mixed)}, indent=2))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user