Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| ec0a5e53ac | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 604d3d4feb | |||
| 287abbd6ee | |||
| f8793f96ac | |||
| 643ebfb849 | |||
| 537516e32e | |||
| ca07b25297 | |||
| fbbff7d5e7 | |||
| bdd841d9af | |||
| 7d33b39859 | |||
| a1074d32e7 | |||
| fd16453691 | |||
| 5494507c39 | |||
| dfb3eda087 | |||
| 83738d4035 | |||
| b77d223f01 | |||
| e178ab8d2d | |||
| cda36408d0 | |||
| 10dbc510b7 | |||
| d3d846f748 | |||
| a5748cb147 | |||
| 0eefb7cfcd | |||
| 9f0d2e2338 | |||
| 2bab120d7c | |||
| d08667df9b | |||
| 9f1d643013 | |||
| 914def9e5c | |||
| 1012355998 | |||
| 1585e986c1 | |||
| e1f1be02ce | |||
| a27dcc028c | |||
| 8a4cc323a3 | |||
| 2a7c77cb56 | |||
| fa94f7a235 | |||
| 0ce1c31fb9 | |||
| 5a0818ee9c | |||
| 1a8093a7be | |||
| ba302dd793 | |||
| 0421bc6d4f | |||
| 5662a54fa7 | |||
| b45165dbc5 | |||
| dbb040aa12 | |||
| 91cf683289 | |||
| 696148d56b | |||
| 19ad2b3e5d | |||
| b88730b7cb | |||
| 6add50311b | |||
| ab27c253c5 | |||
| 8fb10fdf8a | |||
| 0c1d2aa4fc | |||
| 2ff111bae4 | |||
| d7387d9d2c | |||
| 03df14df97 | |||
| d0960bed70 | |||
| 0dd2718c95 | |||
| 4c4eec4b1d | |||
| f5387aa30e | |||
| 3980fbbffb | |||
| 4886305d49 | |||
| 404e2e4d0c | |||
| 3f465aceed | |||
| 3be8b28a8f | |||
| aeefd09f19 | |||
| e57da2f6d5 | |||
| 9508fff282 | |||
| 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,16 @@ 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/pane 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** NO leas ni caches el `@N`: usa `fleet_send_text` (grupo `orchestration`), que resuelve
|
||||
el `pane_id` (`%N`) ESTABLE fresco a partir del `sessionId`/PID en el momento del envío — el `@N` migra
|
||||
con el focus-swap y mandaría el texto al agente equivocado (ver sección Nudge).
|
||||
|
||||
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 +133,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
|
||||
@@ -238,18 +263,24 @@ verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `k
|
||||
### Nudge — `ESTANCADO`
|
||||
|
||||
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
||||
inyectando en su pane tmux:
|
||||
inyectando texto en su pane con la función `fleet_send_text` (grupo `orchestration`):
|
||||
|
||||
```bash
|
||||
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
||||
./fn run fleet_send_text <sessionId> \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." \
|
||||
--socket "$FLEET_SOCKET"
|
||||
```
|
||||
|
||||
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
|
||||
`fleet_send_text` resuelve el **`pane_id` (`%N`) ESTABLE FRESCO** del agente justo antes de enviar (a
|
||||
partir del `sessionId` → PID → pane, leyendo `tmux list-panes -a` en el momento), y manda el texto
|
||||
literal y el `Enter` en invocaciones **separadas**, verificando con `capture-pane` que el texto llegó
|
||||
antes de hacer submit (reintenta si no). Acepta el target por `sessionId` (exacto o prefijo) o por PID.
|
||||
|
||||
```bash
|
||||
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
|
||||
```
|
||||
**NO uses `tmux send-keys -t <window_id @N>` a mano para esto.** El `window_id` (`@N`, p.ej. `@20`) que
|
||||
expone `fleetview list --json` MIGRA cuando el focus-swap recrea windows (`break-pane`+`join-pane`):
|
||||
`@32` → `@34`. Enviar al `@N` viejo (cacheado por el bloque `FLEET-STATE` o leído un instante antes)
|
||||
manda el texto al window equivocado o a otro agente — esa era la causa de "el nudge a veces no llega al
|
||||
agente correcto". `fleet_send_text` nunca usa `@N`; usa el `pane_id` (`%N`), que no migra.
|
||||
|
||||
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
||||
empujón del bot.
|
||||
@@ -302,16 +333,18 @@ 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) |
|
||||
| `fleet_send_text_bash_infra` | Empujar texto al input de UN agente (nudge) resolviendo su `pane_id` (`%N`) ESTABLE FRESCO justo antes de enviar — NO el `window_id` (`@N`), que migra con el focus-swap y manda el texto al agente equivocado. Texto literal + `Enter` en invocaciones separadas, verificado con `capture-pane` + reintento. Guard anti-self. Reemplaza el `tmux send-keys -t <@N>` manual del nudge |
|
||||
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
||||
|
||||
**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": [
|
||||
@@ -65,4 +66,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 @@
|
||||
"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"
|
||||
},
|
||||
"ardour": {
|
||||
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "build_wasm_cpp_app(app_name: string, [--no-budget-check]) -> void"
|
||||
description: "Compila una app C++ del registry (cpp/apps/<name>) a WASM via emscripten. Sale build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}. Falla si gzip > 2 MB."
|
||||
tags: [wasm, emscripten, cpp, build, gamedev, pendiente-usar]
|
||||
tags: [wasm, emscripten, cpp, build, gamedev-engine, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: fleet_send_text
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
signature: "fleet_send_text <sessionId|PID> \"<texto>\" [--socket <s>] [--no-enter] [--retries N] [--dry-run]"
|
||||
description: "Empuja texto a UN agente de la flota tmux de forma fiable, resolviendo su pane_id (%N) ESTABLE FRESCO justo antes de enviar. Es el reemplazo del nudge antiguo del orquestador, que apuntaba al window_id (@N) leido del JSON de la flota: ese @N MIGRA cuando el focus-swap de FleetView (break-pane + join-pane) recrea windows, asi que enviar al @N viejo (cacheado por el bloque FLEET-STATE o leido un instante antes) mandaba el texto al window equivocado o a otro agente. fleet_send_text resuelve sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un ancestro suyo en /proc) es pane_pid, leyendo tmux list-panes -a en el momento del envio, y usa el pane_id (%N) que NO migra. Ademas manda el texto literal (send-keys -l) y el Enter en invocaciones SEPARADAS, verificando con capture-pane que el texto aparecio en el input antes de pulsar Enter; reintenta si no aparece. Guards: NO envia a tu propio pane; error claro si el target no resuelve a un pane vivo. Por defecto EJECUTA; --dry-run imprime el plan sin enviar."
|
||||
tags: [fleet, claude-fleet, orchestration, tmux, nudge, send-keys, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
error_type: error_go_core
|
||||
file_path: "bash/functions/infra/fleet_send_text.sh"
|
||||
tested: true
|
||||
tests:
|
||||
- "golden: envio por PID resuelve el pane_id estable, inyecta el texto y se verifica via capture-pane"
|
||||
- "edge: tras break-pane (focus-swap) el pane_id NO migra y el reenvio sigue llegando"
|
||||
- "edge: resolucion por prefijo de sessionId (sessions/<pid>.json) entrega el texto"
|
||||
- "edge: --dry-run no inyecta nada y reporta status=dry-run"
|
||||
- "error: sessionId no resuelto rc=2; falta texto rc=2; PID sin pane vivo rc=4"
|
||||
- "guard: enviar a la sesion actual (self) rc=3"
|
||||
test_file_path: "bash/functions/infra/fleet_send_text_test.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Primer arg posicional: sessionId del agente (exacto o prefijo) o su PID (todo digitos). Por sessionId se busca en sessions/*.json el que case y su archivo (<pid>.json) da el PID; por PID se usa directo."
|
||||
- name: texto
|
||||
desc: "Segundo arg posicional: el texto a inyectar en el input del agente (entre comillas)."
|
||||
- name: --socket
|
||||
desc: "Socket tmux del perfil FleetView donde vive el pane. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||
- name: --no-enter
|
||||
desc: "Deja el texto en el input sin pulsar Enter (no hace submit). Por defecto envia el Enter en una invocacion separada tras el texto."
|
||||
- name: --retries
|
||||
desc: "Numero de reintentos si el texto no aparece en el pane tras el send (default 2). Cada reintento limpia el input con C-u antes de reenviar."
|
||||
- name: --dry-run
|
||||
desc: "Imprime el plan (PID, sessionId, pane, socket) y NO envia nada. Sin esto, ejecuta."
|
||||
output: "Imprime una linea de plan (target, PID, sessionId, socket, pane resuelto, modo de envio) y una linea final parseable 'pane=%N intento=N status=ok|dry-run'. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es la sesion actual); 4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los reintentos."
|
||||
---
|
||||
|
||||
# fleet_send_text
|
||||
|
||||
Empuja texto al input de **un** agente de la flota tmux de forma fiable. Resuelve el `pane_id` (`%N`) **estable** del agente **fresco** justo antes de enviar (nunca cachea el `window_id` `@N`, que migra con el focus-swap), manda el texto literal y el `Enter` en invocaciones **separadas**, y verifica con `capture-pane` que el texto llegó antes de hacer submit. Es el reemplazo del patrón de nudge antiguo (`tmux send-keys -t <window_id @N>`), que fallaba "a veces" porque enviaba al window equivocado tras un focus-swap.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Nudge a un ejecutor estancado por sessionId (el orquestador lo llama tras detectar ESTANCADO):
|
||||
./fn run fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 \
|
||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: el error path con evidencia. Cierralo o reporta el bloqueo." \
|
||||
--socket "$FLEET_SOCKET"
|
||||
|
||||
# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"):
|
||||
./fn run fleet_send_text 32945650 "Recuerda pushear la rama antes de cerrar."
|
||||
|
||||
# Dejar texto en el input sin hacer submit (--no-enter), o solo ver el plan (--dry-run):
|
||||
./fn run fleet_send_text 48213 "borrador..." --no-enter
|
||||
./fn run fleet_send_text 48213 "texto" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala desde el modo orquestador siempre que necesites **inyectar texto en el input de un agente** de la flota: el **nudge** a un `ESTANCADO`, el aviso de un gap concreto a un ejecutor cuyo cierre falló la verificación, o cualquier mensaje dirigido. Sustituye al `tmux send-keys -t <window_id>` manual. Resuelve el target por sessionId (exacto o prefijo) o por PID. **Solo a idle/ESTANCADO; jamás a un agente en `waiting`/`preguntando`** (esos te reclaman a ti, no un empujón del bot). Para *cerrar* un ejecutor verificado `met` no es esto: usa `kill_fleet_agent`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El bug que arregla — el `window_id` (`@N`) MIGRA**: el focus-swap de FleetView (`tmux_swap_window_into_console.go`) trae el claude objetivo a la console con `break-pane` + `join-pane`, lo que **recrea windows** y cambia el `@N` del agente (`@32` → `@34`). El bloque `FLEET-STATE` y el JSON de la flota pueden traer un `@N` ya viejo. Enviar a ese `@N` manda el texto al window equivocado o a otro agente. Esta función NUNCA usa `@N`: resuelve el `pane_id` (`%N`), que se **preserva** durante toda la vida del pane aunque el pane se mueva de window. Verificado en test: tras `break-pane` el `window_id` pasa de `@0` a `@1` pero el `pane_id` sigue `%0` y el envío sigue llegando.
|
||||
- **Resolución fresca**: el mapa `pane_pid → pane_id` se lee con `tmux -L <socket> list-panes -a` **en el momento del envío**, no se cachea. La resolución sube por los ancestros de `/proc` desde el PID del agente hasta casar un `pane_pid`: cubre tanto `exec claude` (pane_pid == claude pid, match directo, como hace `spawn_fleet_agent`) como un claude lanzado bajo un shell (pane_pid == shell ancestro).
|
||||
- **Texto y Enter separados**: el texto va con `send-keys -l` (literal, sin interpretar nombres de tecla), luego `sleep 0.3`, y el `Enter` en una **invocación aparte**. Mandar texto+Enter juntos hace que el TUI de Claude Code a veces no interprete el Enter como submit. La verificación con `capture-pane` se hace **antes** del Enter (tras el submit el TUI vacía el input y no se podría comprobar). Si el texto no aparece, limpia el input con `C-u` y reintenta (`--retries`, default 2).
|
||||
- **Impura**: inyecta teclas en un pane ajeno. Por defecto EJECUTA; usa `--dry-run` para inspeccionar el plan antes.
|
||||
- **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 autoenvio").
|
||||
- **Verificación por fragmento ancla**: comprueba que aparezcan los primeros 24 caracteres del texto (no el texto completo) para no dar falso negativo cuando el input del TUI wrapea un mensaje largo en varias líneas.
|
||||
- **Socket**: si no pasas `--socket`, usa `$FLEET_SOCKET` o `"fleet"`. Si el agente no está en ese socket, no se encontrará el pane (exit 4).
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
# fleet_send_text — empuja texto a UN agente de la flota tmux de forma fiable.
|
||||
#
|
||||
# El problema que resuelve: el orquestador "nudgea" a los ejecutores con
|
||||
# `tmux send-keys`. El patron antiguo apuntaba al `window_id` (`@N`) leido del
|
||||
# JSON de la flota. Pero el focus-swap de FleetView (`break-pane` + `join-pane`)
|
||||
# RECREA windows, asi que el `@N` de un agente MIGRA (p.ej. `@32` -> `@34`) cada
|
||||
# vez que se entra/sale de su window. Enviar al `@N` viejo (cacheado por el bloque
|
||||
# FLEET-STATE o leido un instante antes) manda el texto al window equivocado o a
|
||||
# otro agente -> "a veces no llega al agente correcto". Ademas, mandar el texto y
|
||||
# el `Enter` en la MISMA invocacion hace que el TUI de Claude Code a veces no
|
||||
# interprete el Enter como submit.
|
||||
#
|
||||
# Esta funcion arregla las dos cosas:
|
||||
# 1. Resuelve el `pane_id` ESTABLE (`%N`) FRESCO justo antes de enviar. El
|
||||
# `pane_id` se preserva durante toda la vida del pane aunque el pane se mueva
|
||||
# de window con break/join — NO migra como el `window_id`. La resolucion va
|
||||
# sessionId -> PID (sessions/<PID>.json) -> el pane cuyo proceso (o un
|
||||
# ancestro suyo en /proc) es `pane_pid`, leyendo `tmux list-panes -a` en el
|
||||
# momento del envio.
|
||||
# 2. Manda el texto literal (`send-keys -l`), espera un poco, y el `Enter` en
|
||||
# una invocacion SEPARADA. Verifica con `capture-pane` que el texto aparecio
|
||||
# en el pane antes de pulsar Enter; si no, reintenta.
|
||||
#
|
||||
# Guards: NO envia a tu propio pane (la sesion que invoca la funcion). Error claro
|
||||
# si el sessionId/PID no resuelve a un pane vivo.
|
||||
#
|
||||
# Funcion IMPURA: inyecta teclas en un pane tmux ajeno. Por defecto EJECUTA (es el
|
||||
# caso de uso del bot: nudgear a un ejecutor). Usa --dry-run para ver el plan sin
|
||||
# enviar nada.
|
||||
#
|
||||
# Overrides de entorno (testabilidad, no para uso normal):
|
||||
# FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions
|
||||
# FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc)
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
# Resuelve el pane_id (%N) ESTABLE de un PID dado, leyendo el mapa fresco de panes
|
||||
# del socket. Sube por la cadena de ancestros del PID en /proc hasta encontrar un
|
||||
# `pane_pid` del mapa: cubre tanto el caso `exec claude` (pane_pid == claude pid,
|
||||
# match directo) como el de un claude lanzado bajo un shell (pane_pid == shell
|
||||
# ancestro). Imprime el pane_id y devuelve 0 si lo encuentra; 1 si no.
|
||||
# $1 = PID objetivo
|
||||
# $2 = texto del mapa "pane_pid pane_id" (una linea por pane)
|
||||
_fleet_resolve_pane_for_pid() {
|
||||
local p="${1:-}" panes_map="${2:-}" guard=0 pane_id
|
||||
while [[ -n "$p" && "$p" != "0" && "$p" != "1" ]]; do
|
||||
pane_id="$(awk -v pp="$p" '$1==pp {print $2; exit}' <<<"$panes_map")"
|
||||
if [[ -n "$pane_id" ]]; then
|
||||
printf '%s\n' "$pane_id"
|
||||
return 0
|
||||
fi
|
||||
p="$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
fleet_send_text() {
|
||||
local target="" txt="" socket="" do_enter=1 dry=0 retries=2
|
||||
local got_target=0 got_text=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--socket) shift; socket="${1:-}" ;;
|
||||
--no-enter) do_enter=0 ;;
|
||||
--retries) shift; retries="${1:-2}" ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: fleet_send_text <sessionId|PID> "<texto>" [--socket <s>] [--no-enter] [--retries N] [--dry-run]
|
||||
|
||||
Empuja <texto> a UN agente de la flota tmux resolviendo su pane_id (%N) ESTABLE
|
||||
FRESCO justo antes de enviar (no cachea el window_id @N, que migra con el
|
||||
focus-swap). Manda el texto literal y el Enter en invocaciones separadas, y
|
||||
verifica con capture-pane que el texto aparecio antes de pulsar Enter;
|
||||
reintenta si no.
|
||||
|
||||
Argumentos:
|
||||
<sessionId|PID> Primer posicional: sessionId del agente (exacto o prefijo) o
|
||||
su PID (todo digitos). Por sessionId se busca en
|
||||
sessions/*.json el que case; su archivo (<pid>.json) da el PID.
|
||||
"<texto>" Segundo posicional: el texto a inyectar en el input del agente.
|
||||
|
||||
Opciones:
|
||||
--socket <s> Socket tmux del perfil FleetView. Default: $FLEET_SOCKET, o "fleet".
|
||||
--no-enter Deja el texto en el input sin pulsar Enter (no hace submit).
|
||||
--retries N Reintentos si el texto no aparece tras el send (default 2).
|
||||
--dry-run Imprime el plan (PID, sessionId, pane, socket) y NO envia nada.
|
||||
-h, --help Esta ayuda.
|
||||
|
||||
Salida: linea de resultado con `pane=%N` usado e `intento=N`. Exit 0 ok/dry-run;
|
||||
2 uso incorrecto o target no resuelto; 3 guard (target es la sesion actual);
|
||||
4 no se encontro pane vivo para el target; 5 enviado pero no verificado tras los
|
||||
reintentos.
|
||||
|
||||
Ejemplos:
|
||||
fleet_send_text 32945650-a4e1-472b-90c9-5b38ef60a463 "Cierra tu DoD o reporta el bloqueo." --socket "$FLEET_SOCKET"
|
||||
fleet_send_text 32945650 "Falta el error path con evidencia." # por prefijo de sessionId
|
||||
fleet_send_text 48213 "texto" --no-enter --dry-run # por PID, solo ver el plan
|
||||
USAGE
|
||||
return 0 ;;
|
||||
--*)
|
||||
echo "fleet_send_text: opcion desconocida '$1' (usa -h)" >&2
|
||||
return 2 ;;
|
||||
*)
|
||||
if [[ "$got_target" -eq 0 ]]; then
|
||||
target="$1"; got_target=1
|
||||
elif [[ "$got_text" -eq 0 ]]; then
|
||||
txt="$1"; got_text=1
|
||||
else
|
||||
echo "fleet_send_text: argumento extra '$1' (target y texto ya fijados)" >&2
|
||||
return 2
|
||||
fi ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ "$got_target" -eq 0 ]] && {
|
||||
echo "fleet_send_text: falta el target (sessionId o PID). Usa -h." >&2
|
||||
return 2
|
||||
}
|
||||
[[ "$got_text" -eq 0 ]] && {
|
||||
echo "fleet_send_text: falta el texto a enviar. Usa -h." >&2
|
||||
return 2
|
||||
}
|
||||
[[ "$retries" =~ ^[0-9]+$ ]] || {
|
||||
echo "fleet_send_text: --retries debe ser un entero (recibido '$retries')" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}"
|
||||
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || {
|
||||
echo "fleet_send_text: tmux no esta instalado" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resolver (PID, sessionId) a partir del target. Mismo patron que
|
||||
# kill_fleet_agent: por PID directo, o por sessionId (exacto/prefijo)
|
||||
# buscando en sessions/*.json.
|
||||
# -----------------------------------------------------------------------
|
||||
local pid="" sid=""
|
||||
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
||||
pid="$target"
|
||||
local sfile="$sessions_dir/$pid.json"
|
||||
if [[ -f "$sfile" ]] && command -v jq >/dev/null 2>&1; then
|
||||
sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)"
|
||||
fi
|
||||
else
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "fleet_send_text: jq no esta instalado (necesario para resolver el sessionId)" >&2
|
||||
return 1
|
||||
}
|
||||
local f base candidate_sid
|
||||
for f in "$sessions_dir"/*.json; do
|
||||
[[ -f "$f" ]] || continue
|
||||
candidate_sid="$(jq -r '.sessionId // ""' "$f" 2>/dev/null || true)"
|
||||
[[ -z "$candidate_sid" ]] && continue
|
||||
if [[ "$candidate_sid" == "$target" || "$candidate_sid" == "$target"* ]]; then
|
||||
base="$(basename "$f" .json)"
|
||||
pid="$base"
|
||||
sid="$candidate_sid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[[ -z "$pid" ]] && {
|
||||
echo "fleet_send_text: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Guard — anti-self: no enviar a la sesion que invoca la funcion.
|
||||
# -----------------------------------------------------------------------
|
||||
local self_pid="${FN_FLEET_SELF_PID:-}"
|
||||
if [[ -z "$self_pid" ]]; then
|
||||
local walk="$$" guard=0 comm
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
self_pid="$walk"
|
||||
break
|
||||
fi
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
fi
|
||||
if [[ -n "$self_pid" && "$pid" == "$self_pid" ]]; then
|
||||
echo "fleet_send_text: REHUSADO — el target (PID $pid) es la sesion actual. No me autoenvio." >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resolver el pane_id (%N) ESTABLE FRESCO. Mapa pane_pid->pane_id del socket
|
||||
# leido AHORA; subir por ancestros del PID hasta casar un pane_pid.
|
||||
# -----------------------------------------------------------------------
|
||||
local panes_map pane=""
|
||||
panes_map="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{pane_id}' 2>/dev/null || true)"
|
||||
if [[ -n "$panes_map" ]]; then
|
||||
pane="$(_fleet_resolve_pane_for_pid "$pid" "$panes_map" || true)"
|
||||
fi
|
||||
|
||||
[[ -z "$pane" ]] && {
|
||||
echo "fleet_send_text: no se encontro un pane vivo para el target '$target' (PID $pid) en el socket '$socket'." >&2
|
||||
return 4
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Plan.
|
||||
# -----------------------------------------------------------------------
|
||||
local enter_desc; [[ "$do_enter" -eq 1 ]] && enter_desc="texto + Enter separado" || enter_desc="solo texto (--no-enter)"
|
||||
echo "fleet_send_text — target: $target PID: $pid sessionId: ${sid:-?} socket: $socket pane: $pane envio: $enter_desc retries: $retries"
|
||||
|
||||
if [[ "$dry" -eq 1 ]]; then
|
||||
echo "DRY-RUN: no se ha enviado nada."
|
||||
echo "pane=$pane intento=0 status=dry-run"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Enviar + verificar. El texto se manda literal (-l); el Enter va en una
|
||||
# invocacion separada tras un sleep. Verificamos ANTES del Enter (el texto
|
||||
# esta en el input; tras Enter el TUI vacia el input y no se podria verificar).
|
||||
# Si el texto no aparece, limpiamos el input (C-u) y reintentamos.
|
||||
# -----------------------------------------------------------------------
|
||||
local anchor="${txt:0:24}" # fragmento ancla (evita falsos negativos por wrapping)
|
||||
local i cap ok=0 used_try=0
|
||||
for (( i=1; i<=retries+1; i++ )); do
|
||||
tmux -L "$socket" send-keys -t "$pane" -l -- "$txt" 2>/dev/null || true
|
||||
sleep 0.3
|
||||
cap="$(tmux -L "$socket" capture-pane -p -t "$pane" 2>/dev/null || true)"
|
||||
if grep -qF -- "$anchor" <<<"$cap"; then
|
||||
ok=1; used_try="$i"
|
||||
break
|
||||
fi
|
||||
# No aparecio: limpiar el input antes de reintentar.
|
||||
tmux -L "$socket" send-keys -t "$pane" C-u 2>/dev/null || true
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [[ "$ok" -ne 1 ]]; then
|
||||
echo "fleet_send_text: texto enviado pero NO verificado en el pane $pane tras $((retries+1)) intentos." >&2
|
||||
echo "pane=$pane intento=$((retries+1)) status=unverified" >&2
|
||||
return 5
|
||||
fi
|
||||
|
||||
# Texto presente en el input. Ahora el Enter (separado) para hacer submit.
|
||||
if [[ "$do_enter" -eq 1 ]]; then
|
||||
tmux -L "$socket" send-keys -t "$pane" Enter 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "fleet_send_text: OK — texto inyectado en el pane $pane (intento $used_try)$([[ "$do_enter" -eq 1 ]] && echo " + Enter")."
|
||||
echo "pane=$pane intento=$used_try status=ok"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
fleet_send_text "$@"
|
||||
fi
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para fleet_send_text. Levanta un socket tmux PROPIO de test
|
||||
# (fleet_test_<pid>, nunca el socket "fleet" real) con un pane `cat` vivo, y
|
||||
# verifica: envio + verificacion via capture-pane (golden), supervivencia al
|
||||
# focus-swap (break-pane preserva el pane_id), resolucion por sessionId fake,
|
||||
# y los paths de error/guard. No toca la flota real ni ningun agente.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/fleet_send_text.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_not_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "FAIL: $test_name — should NOT contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
else
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_rc() {
|
||||
local test_name="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
echo "PASS: $test_name (rc=$actual)"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected rc=$expected, got rc=$actual"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "SKIP: tmux no instalado"; exit 0; }
|
||||
|
||||
# --- Socket de test PROPIO + pane `cat` vivo (con echo de tty) ---
|
||||
SOCK="fleet_test_$$"
|
||||
TMP="$(mktemp -d)"
|
||||
SESS="$TMP/sessions"
|
||||
mkdir -p "$SESS"
|
||||
|
||||
cleanup() {
|
||||
tmux -L "$SOCK" kill-server 2>/dev/null || true
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tmux -L "$SOCK" new-session -d -s t -x 120 -y 30 'cat'
|
||||
sleep 0.4
|
||||
|
||||
PANE_PID="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid}' | head -n1)"
|
||||
PANE_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
WIN_ID0="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
echo "INFO: socket=$SOCK pane_pid=$PANE_PID pane_id=$PANE_ID0 window_id=$WIN_ID0"
|
||||
|
||||
# self_pid forzado a un PID que nunca sera target en los tests golden.
|
||||
export FN_FLEET_SELF_PID=1
|
||||
export FN_FLEET_SESSIONS_DIR="$SESS"
|
||||
|
||||
# --- Test 1 (golden): enviar por PID, verificar via capture-pane ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "HOLA_FLEET_123" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "golden: envio por PID sale 0" 0 "$rc"
|
||||
assert_contains "golden: reporta status=ok" "status=ok" "$out"
|
||||
assert_contains "golden: reporta el pane_id estable" "pane=$PANE_ID0" "$out"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID0")"
|
||||
assert_contains "golden: el texto llego al pane (capture-pane)" "HOLA_FLEET_123" "$cap"
|
||||
|
||||
# limpiar input del cat
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-u; sleep 0.2
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID0" C-l 2>/dev/null || true; sleep 0.2
|
||||
|
||||
# --- Test 2 (edge focus-swap): mover el pane a otra window, pane_id NO migra ---
|
||||
# Anadimos un segundo pane para poder break-pane el nuestro a una window nueva.
|
||||
tmux -L "$SOCK" split-window -t "$WIN_ID0" -d 'cat'; sleep 0.3
|
||||
tmux -L "$SOCK" break-pane -d -s "$PANE_ID0"; sleep 0.3
|
||||
WIN_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{window_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
PANE_ID1="$(tmux -L "$SOCK" list-panes -a -F '#{pane_pid} #{pane_id}' | awk -v p="$PANE_PID" '$1==p{print $2}')"
|
||||
echo "INFO: tras break-pane: pane_id=$PANE_ID1 (era $PANE_ID0) window_id=$WIN_ID1 (era $WIN_ID0)"
|
||||
assert_contains "edge: pane_id NO cambia tras mover de window" "$PANE_ID0" "$PANE_ID1"
|
||||
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "TRAS_MOVER_456" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: reenvio tras focus-swap sale 0" 0 "$rc"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_contains "edge: el texto sigue llegando tras mover de window" "TRAS_MOVER_456" "$cap"
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||
|
||||
# --- Test 3 (edge): resolver por sessionId (sessions/<pid>.json fake) ---
|
||||
echo "{\"sessionId\":\"test-sid-aaa-111\",\"cwd\":\"/tmp/x\"}" > "$SESS/$PANE_PID.json"
|
||||
set +e
|
||||
out=$(fleet_send_text "test-sid-aaa" "VIA_SID_789" --socket "$SOCK" --no-enter --retries 1 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: resolucion por prefijo de sessionId sale 0" 0 "$rc"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_contains "edge: texto llego resolviendo por sessionId" "VIA_SID_789" "$cap"
|
||||
tmux -L "$SOCK" send-keys -t "$PANE_ID1" C-u; sleep 0.2
|
||||
|
||||
# --- Test 4 (edge): --dry-run no envia nada ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" "NO_DEBE_APARECER_000" --socket "$SOCK" --no-enter --dry-run 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "edge: dry-run sale 0" 0 "$rc"
|
||||
assert_contains "edge: dry-run reporta status=dry-run" "status=dry-run" "$out"
|
||||
cap="$(tmux -L "$SOCK" capture-pane -p -t "$PANE_ID1")"
|
||||
assert_not_contains "edge: dry-run NO inyecto texto" "NO_DEBE_APARECER_000" "$cap"
|
||||
|
||||
# --- Test 5 (error): sessionId que no resuelve a PID -> rc 2 ---
|
||||
set +e
|
||||
out=$(fleet_send_text "sid-inexistente-zzz" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: sessionId no resuelto sale 2" 2 "$rc"
|
||||
assert_contains "error: mensaje de target no resuelto" "no se pudo resolver" "$out"
|
||||
|
||||
# --- Test 6 (error): falta el texto -> rc 2 ---
|
||||
set +e
|
||||
out=$(fleet_send_text "$PANE_PID" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: falta texto sale 2" 2 "$rc"
|
||||
|
||||
# --- Test 7 (guard anti-self): target == self_pid -> rc 3 ---
|
||||
set +e
|
||||
out=$(FN_FLEET_SELF_PID="$PANE_PID" fleet_send_text "$PANE_PID" "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "guard: enviar a la sesion actual sale 3" 3 "$rc"
|
||||
assert_contains "guard: mensaje anti-self" "No me autoenvio" "$out"
|
||||
|
||||
# --- Test 8 (error): PID sin pane vivo -> rc 4 ---
|
||||
set +e
|
||||
out=$(fleet_send_text 999999 "x" --socket "$SOCK" 2>&1); rc=$?
|
||||
set -e
|
||||
assert_rc "error: PID sin pane vivo sale 4" 4 "$rc"
|
||||
assert_contains "error: mensaje no pane vivo" "no se encontro un pane vivo" "$out"
|
||||
|
||||
# --- Resumen ---
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "PASS: $PASS FAIL: $FAIL"
|
||||
echo "================================"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
@@ -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,8 +242,17 @@ USAGE
|
||||
fi
|
||||
|
||||
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
|
||||
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
|
||||
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
|
||||
left_cmd="$envpfx exec $(printf '%q' "$bin")"
|
||||
# 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
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)"
|
||||
description: "Lifecycle del engine de audio basado en miniaudio (single-header, public domain). Inicializa device default, expone master volume, y libera recursos. Cross-platform: Windows/Linux/macOS via WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten. Issue 0072b — runtime gamedev nucleo. Esta TU es la unica del proyecto que define MINIAUDIO_IMPLEMENTATION."
|
||||
tags: [gamedev, audio, miniaudio]
|
||||
tags: [gamedev-engine, audio, miniaudio]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)"
|
||||
description: "Reproduccion de audio sobre fn::audio::Engine: carga sonidos con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. Cross-platform via miniaudio. Issue 0072b — runtime gamedev nucleo."
|
||||
tags: [gamedev, audio, miniaudio]
|
||||
tags: [gamedev-engine, audio, miniaudio]
|
||||
uses_functions: ["audio_engine_cpp_gamedev"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: pure
|
||||
signature: "world_to_screen(Camera2D, Vec2) -> Vec2; screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])"
|
||||
description: "Camara ortografica 2D pura: pos (centro), zoom, rotacion (rad) y viewport en pixeles. Conversiones world<->screen, AABB visible y matriz view-projection 4x4 column-major lista para cualquier renderer (sokol_gfx, OpenGL, WebGPU). Fast-path sin trig si rotation==0. Issue 0072b."
|
||||
tags: [gamedev, camera, 2d, math, pure]
|
||||
tags: [gamedev-engine, camera, 2d, math, pure]
|
||||
uses_functions: []
|
||||
uses_types: ["Vec2_cpp_core", "Rect_cpp_core"]
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "loop_run(SDL_Window*, const LoopCfg&) -> void"
|
||||
description: "Game loop fixed-timestep estilo Glenn Fiedler ('Fix Your Timestep'). Desacopla simulacion (on_fixed_update con dt fijo) de renderizado (on_render con factor de interpolacion). Acumulador con cap anti spiral-of-death. Branch automatico desktop (while loop bloqueante) vs __EMSCRIPTEN__ (emscripten_set_main_loop). Issue 0072b."
|
||||
tags: [gamedev, game-loop, sdl3, wasm, fixed-timestep]
|
||||
tags: [gamedev-engine, game-loop, sdl3, wasm, fixed-timestep]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)"
|
||||
description: "Snapshot unificado de input por frame para SDL3. Mapea keyboard (WASD+arrows), mouse, gamepad (SDL_Gamepad) y touch a botones logicos (left/right/up/down/action_a..y/start/back) y ejes analogicos. Expone flags *_pressed con rising edge limpio cada frame. Issue 0072b — runtime gamedev PC + WASM."
|
||||
tags: [gamedev, input, sdl3, touch, gamepad]
|
||||
tags: [gamedev-engine, input, sdl3, touch, gamepad]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: pure
|
||||
signature: "make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain"
|
||||
description: "Builders puros para inicializar sokol_gfx encima de un GL context creado por SDL3 (no por sokol_app). Construye sg_environment con defaults RGBA8 + depth/stencil y sg_swapchain con el default framebuffer del contexto activo. Issue 0072b — base del runtime gamedev en PC + WASM."
|
||||
tags: [gamedev, sokol, gfx, sdl3, wasm]
|
||||
tags: [gamedev-engine, sokol, gfx, sdl3, wasm]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end"
|
||||
description: "Batched textured quad renderer sobre sokol_gfx. Begin/draw/end con auto-flush por atlas change o capacity full. Vertex layout pos+uv+color, alpha blending estandar, GLSL 330 / GLES 300. Issue 0072b runtime gamedev — base de plataformeros, top-down, UI sprites."
|
||||
tags: [gamedev, gfx, sokol, sprite, batch, 2d]
|
||||
tags: [gamedev-engine, gfx, sokol, sprite, batch, 2d]
|
||||
uses_functions:
|
||||
- sokol_setup_cpp_gfx
|
||||
uses_types:
|
||||
|
||||
@@ -14,6 +14,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
|
||||
| Grupo | N | Que cubre |
|
||||
|---|---|---|
|
||||
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
||||
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
||||
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
||||
| [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync |
|
||||
@@ -42,7 +44,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 +59,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 +71,11 @@ 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) | 126 | 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`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
|
||||
| [comfyui-skill](comfyui-skill.md) | 17 | 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-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
|
||||
| [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,144 @@
|
||||
# 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-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
|
||||
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
|
||||
|
||||
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 |
|
||||
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
|
||||
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
|
||||
| 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`.
|
||||
|
||||
### 04b · styles (presets / catálogo)
|
||||
|
||||
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
|
||||
|
||||
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
|
||||
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
|
||||
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
|
||||
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
|
||||
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
|
||||
|
||||
### 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.
|
||||
|
||||
### 05b · audio
|
||||
|
||||
- `comfyui_build_audio_workflow_py_ml` (pura) — txt2audio ACE-Step: TextEncodeAceStepAudio (tags + lyrics) → EmptyAceStepLatentAudio → KSampler → VAEDecodeAudio → SaveAudio(.flac).
|
||||
|
||||
### 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`, `comfyui_fetch_output_audio_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`.
|
||||
- Templates oficiales (paquete `comfyui-workflow-templates`): `comfyui_list_templates_py_ml`, `comfyui_extract_template_py_ml`.
|
||||
- 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,357 @@
|
||||
# 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"`.
|
||||
|
||||
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
|
||||
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
|
||||
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
|
||||
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
|
||||
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
|
||||
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
|
||||
|
||||
## 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": "SD15_3d_render_redmond.safetensors", "strength_model": 0.9},
|
||||
{"name": "SD15_detail_tweaker.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,101 @@
|
||||
# ComfyUI Styles — presets y catálogo de estilo
|
||||
|
||||
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
|
||||
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
|
||||
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
|
||||
prompt, el estilo se trata como un dato curado y reusable.
|
||||
|
||||
Dos vertientes complementarias:
|
||||
|
||||
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
|
||||
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
|
||||
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
|
||||
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
|
||||
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
|
||||
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
|
||||
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
|
||||
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
|
||||
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
|
||||
|
||||
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
|
||||
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
|
||||
|
||||
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
|
||||
|
||||
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
|
||||
generar un asset con look consistente.
|
||||
|
||||
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
|
||||
from ml.comfyui_append_styles import comfyui_append_styles
|
||||
|
||||
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
|
||||
# directo (best-effort: {} si el LLM falla).
|
||||
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
|
||||
|
||||
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
|
||||
if nuevos:
|
||||
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
|
||||
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
|
||||
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
|
||||
```
|
||||
|
||||
### B) Aplicar un style preset gamedev a un sujeto
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||
|
||||
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
|
||||
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
|
||||
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
|
||||
wf = comfyui_build_enemy_creature_workflow(
|
||||
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||
)
|
||||
```
|
||||
|
||||
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
|
||||
|
||||
```python
|
||||
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
|
||||
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
|
||||
print(len(todos), list(todos)[:5])
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
|
||||
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
|
||||
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
|
||||
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
|
||||
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
|
||||
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
|
||||
dict de modificadores, pero el selector no aparecerá en el grafo.
|
||||
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
|
||||
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
|
||||
aquí por afinidad de capacidad (estilo reutilizable).
|
||||
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
|
||||
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
|
||||
la pone el `subject` del usuario.
|
||||
@@ -0,0 +1,347 @@
|
||||
# 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"`.
|
||||
|
||||
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
|
||||
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
|
||||
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
|
||||
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
|
||||
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
|
||||
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
|
||||
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
|
||||
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
|
||||
> propia página. El mapa cross-grupo de capacidades está en
|
||||
> [comfyui-overview.md](comfyui-overview.md).
|
||||
|
||||
## 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, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **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**. |
|
||||
|
||||
### Audio (txt2audio, ACE-Step) — dominio `ml` (tag `audio-generation`)
|
||||
|
||||
ComfyUI ≥ 0.26.0 trae nodos de **audio nativos**. `build_audio_workflow` cubre **ACE-Step v1**
|
||||
(`AUDIO_ace_step_v1_3.5b.safetensors`, Apache 2.0): música y SFX por texto, con `lyrics` opcional
|
||||
para voz cantada. El resultado es un `.flac` vía `VAEDecodeAudio → SaveAudio`, que `fetch_output_audio`
|
||||
localiza y baja a disco (los nodos de audio exponen su salida bajo la clave `"audio"` de `/history`,
|
||||
no `"images"`).
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_audio_workflow_py_ml](../../python/functions/ml/comfyui_build_audio_workflow.md) | `build_audio_workflow(ckpt_name, prompt, *, lyrics='', seconds=10.0, seed=0, steps=50, cfg=5.0, sampler_name='euler', scheduler='simple', shift=5.0, lyrics_strength=1.0, filename_prefix='audio/comfy_audio') -> dict` | Builder **txt2audio (ACE-Step)** en API format: CheckpointLoaderSimple → TextEncodeAceStepAudio (tags=prompt + lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) → ModelSamplingSD3(shift) → KSampler → VAEDecodeAudio → SaveAudio(.flac). La guía va por `cfg`; `lyrics` opcional para voz cantada. **Pura**. |
|
||||
| [comfyui_fetch_output_audio_py_ml](../../python/functions/ml/comfyui_fetch_output_audio.md) | `fetch_output_audio(prompt_id, *, server='127.0.0.1:8188', dest=None, outputs=None, timeout=120.0) -> dict` | Localiza y descarga el output de **audio** (`.flac`/`.wav`/`.mp3`/`.opus`/`.ogg`/`.m4a`) de `/history` vía GET `/view`. Cubre SaveAudio/SaveAudioMP3/Opus/Advanced (bajo la clave `"audio"`). Hermana de `fetch_output_image`/`video`/`mesh`. Acepta `outputs=` de `wait_result` para no re-consultar `/history`. Impura. |
|
||||
|
||||
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
|
||||
|
||||
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
|
||||
reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnlyCheckpointLoader
|
||||
→ CLIPVisionEncode → Hunyuan3Dv2Conditioning → EmptyLatentHunyuan3Dv2 → KSampler →
|
||||
VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma +
|
||||
VAE 3D + encoder de imagen en un `.safetensors`). Salida **shape-only** (sin color/textura). Detalle
|
||||
y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. 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. |
|
||||
|
||||
### Templates oficiales — dominio `ml` (tag `templates`)
|
||||
|
||||
Los workflows del menú **"Browse Templates"** del frontend se distribuyen en el paquete pip
|
||||
`comfyui-workflow-templates` (desde la 0.10.x un meta-paquete multi-bundle con API en
|
||||
`comfyui_workflow_templates_core`). Estas dos funciones leen ese catálogo localizando el intérprete
|
||||
de ComfyUI y usando su API oficial vía subprocess (el paquete vive en el venv de ComfyUI, no en el
|
||||
del registry). Sirven para descubrir grafos oficiales y arrancar un workflow desde una plantilla
|
||||
probada en vez de construirlo a mano. Si no hay un ComfyUI con el paquete, devuelven `ok=False` con
|
||||
un error accionable, sin lanzar.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
||||
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
||||
|
||||
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
|
||||
|
||||
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
|
||||
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
|
||||
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
|
||||
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
|
||||
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
|
||||
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
|
||||
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
|
||||
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
|
||||
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Capability group: `gamedev-2d` — assets 2D para Godot (generación + post-proceso + puente)
|
||||
|
||||
Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
|
||||
(generación) y **Godot 4** (consumo). Tres capas:
|
||||
|
||||
1. **Builders de workflow 2D** (`gamedev-2d`, GPU): construyen el dict (API format)
|
||||
de los workflows ComfyUI para pixel-art, tiles seamless, isométrico, sprites de
|
||||
personaje y VFX en bucle. Son **puros** (no tocan GPU al construir); el coste GPU
|
||||
está al enviar con `comfyui_submit_workflow`.
|
||||
2. **Post-proceso determinista** (CPU): pixelizar, recortar a alpha.
|
||||
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
||||
con sus import settings.
|
||||
|
||||
Tag único del grupo: `gamedev-2d` — **47 funciones**: 36 builders de workflow (31 de
|
||||
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
|
||||
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
|
||||
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
||||
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
||||
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
||||
|
||||
Documento hermano del grupo `comfyui` (generación genérica de imágenes/video/3D).
|
||||
Diseño del puente: `docs/comfyui-godot-integration.md`. Planes origen: `reports/0135`
|
||||
(pixelart), `reports/0139` (entornos/tiles/iso), `reports/0137` (personajes/sprites),
|
||||
`reports/0140` (VFX), `reports/0143` (ronda 2b: builders), `reports/0147` (item icons),
|
||||
`reports/0149` (parallax background).
|
||||
|
||||
## Builders de workflow 2D (`gamedev-2d`, puros — generación)
|
||||
|
||||
Construyen el dict API format listo para `comfyui_submit_workflow`. Cada uno compone
|
||||
funciones existentes del registry (`comfyui_build_txt2img_workflow`, `comfyui_inject_*`,
|
||||
`comfyui_build_ipadapter_workflow`) — no reinventan el grafo. class_types verificados
|
||||
contra `/object_info` del server (8GB lowvram). Probados e2e en GPU: pixelart, seamless,
|
||||
VFX (ver `reports/0143`).
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_build_pixelart_workflow_py_ml` | `(positive, negative=…, *, ckpt_name="juggernaut_xl_v11…", pixel_lora="SDXL_pixel-art…", use_lcm=True, …) -> dict` | Fase 1 pixel-art: SDXL + LoRA SDXL_pixel-art (+ LCM 8 steps). El pixel-perfect es post (`comfyui_pixelize_image`). |
|
||||
| `comfyui_build_seamless_tile_workflow_py_ml` | `(positive, negative="", *, tiling="enable", copy_model="Make a copy", circular_vae=True, material_lora=None, …) -> dict` | Textura tileable: `SeamlessTile` (Conv2d circular) + `CircularVAEDecode`. Coste VRAM ≈0. |
|
||||
| `comfyui_build_isometric_workflow_py_ml` | `(positive, negative=…, *, iso_lora="SD15_isometric_game_assets…", grid_image=None, …) -> dict` | Asset iso 2:1: LoRA iso + ControlNet grid opcional. |
|
||||
| `comfyui_build_sprite_sheet_workflow_py_ml` | `(subject, *, ref_image=None, pose_skeleton=None, char_lora=None, transparent=True, …) -> dict` | UN sprite de personaje: IPAdapter-FaceID + LoRA + ControlNet OpenPose (Advanced, end<1) + Rembg. Varias poses → sheet. SD1.5. |
|
||||
| `comfyui_build_vfx_spritesheet_workflow_py_ml` | `(prompt, *, motion_model="mm_sd_v15_v2.ckpt", num_frames=16, closed_loop=True, lora=None, …) -> dict` | N frames AnimateDiff loop sobre negro (insumo de luma→alpha). 8GB: 16f@512² revienta, usar ≤8f@512² o bajar resolución. |
|
||||
| `comfyui_build_item_icon_workflow_py_ml` | `(item, *, style="game icon, clean, centered", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN icono de item de inventario (espada/poción/anillo/libro/escudo): txt2img cuadrado + prompt scaffold de icono + LoRA estilo opcional + Rembg (alpha). Set coherente = mismo style/checkpoint/lora por item. SD1.5. |
|
||||
| `comfyui_build_portrait_avatar_workflow_py_ml` | `(character, *, style="character portrait", ref_face=None, checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN retrato/avatar de personaje (busto centrado, cara al espectador, fondo simple): txt2img + prompt scaffold de retrato + FaceDetailer (cara nítida) + LoRA estilo opcional; `ref_face` → IPAdapter-FaceID para rostro consistente entre retratos. Diálogo/perfil/selección. SD1.5. |
|
||||
| `comfyui_build_emote_workflow_py_ml` | `(character, expression, *, ref_face=None, style="character portrait", checkpoint="dreamshaper_8…", size=512, facedetailer=True, lora=None, …) -> dict` | UN emote/expresión facial del MISMO personaje (alegre/triste/enfadado/sorprendido/neutral…) para diálogo, retratos reactivos o emotes de chat: txt2img + prompt scaffold de emote (`portrait of {character}, {expression} expression, emote, clean background`) + FaceDetailer (conserva la expresión); `ref_face` → IPAdapter-FaceID para que varíe SOLO la expresión y el rostro sea el mismo. UNA expresión por llamada; set = mismas claves variando `expression` → `comfyui_build_grid`. Probado e2e en GPU (`reports/0151`). SD1.5. |
|
||||
| `comfyui_build_parallax_background_workflow_py_ml` | `(scene, *, style="game background, side-scroller…", layers=3, checkpoint="dreamshaper_8…", depth_node="DepthAnythingV2Preprocessor", width=1024, height=512, …) -> dict` | Fondo en capas para parallax 2.5D: genera el fondo apaisado (txt2img) + su depth map (`DepthAnythingV2Preprocessor` sobre el VAEDecode), dos SaveImage. El split en N bandas por profundidad es post (GAP: `split_parallax_layers`, aún no creada). Probado e2e en GPU (`reports/0149`). SD1.5. |
|
||||
| `comfyui_build_normal_map_workflow_py_ml` | `(image, *, method="normal", strength=1.0, resolution=512, bg_threshold=0.1, filename_prefix="normal_map") -> dict` | Normal/depth map de un sprite existente para iluminación dinámica 2.5D (Godot CanvasItem `normal_map`, Unity sprite normal). `LoadImage → preprocesador controlnet_aux → SaveImage`. `method`: `normal` (default, `BAE-NormalMapPreprocessor`, normal canónico **azul/violeta** usable directo en motor), `normal_midas` (MiDaS, único con `strength`→`a`, paleta no canónica), `normal_dsine` (DSINE), `depth` (`DepthAnythingV2`, height en gris). `image` debe estar en `input/` de ComfyUI. Coste VRAM ≈0. Probado e2e en GPU (`reports/0150`). |
|
||||
| `comfyui_build_ui_hud_workflow_py_ml` | `(element, *, ui_style="fantasy game UI", checkpoint="dreamshaper_8…", size=512, transparent=True, lora=None, …) -> dict` | UN elemento de interfaz/HUD de juego (botón, marco/panel, barra de vida/maná/XP, icono de UI, cursor, viñeta de menú): txt2img cuadrado + prompt scaffold de UI (`{element}, {ui_style}, game UI element, centered, clean, plain background…`) + LoRA estilo opcional + Rembg (alpha). HUD coherente = mismo `ui_style`/`checkpoint`/`lora` por pieza, varía solo `element`. El texto/label lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU (`reports/0152`). SD1.5. |
|
||||
| `comfyui_build_dialogue_box_workflow_py_ml` | `(box_style="fantasy RPG dialogue box", *, shape="rounded panel", checkpoint="dreamshaper_8…", width=768, height=256, transparent=True, seed=0, lora=None, …) -> dict` | EL contenedor de diálogo / bocadillo / panel de texto de juego (RPG, visual novel, aventura): marco **apaisado** (`width>height`, 768×256) con borde decorativo y un **interior plano/vacío** reservado para que el motor renderice el texto de la conversación encima → `{box_style}, {shape}, game UI dialogue box frame, ornate border, empty flat interior for text, plain background` + LoRA estilo opcional + Rembg (alpha). **DISTINTO de `ui_hud` (elementos sueltos: botón/barra/icono)**: esto es el panel-contenedor completo. `shape` (rounded panel/scroll parchment/stone tablet/speech bubble…) + set coherente = mismo `box_style`/`shape`/`checkpoint`/`lora`. El interior se mantiene liso (negativo rechaza `busy/decorated interior`); el texto lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `medieval fantasy dialogue box, wood and gold` 768×256 RGBA, panel madera+oro con interior plano y alpha (`reports/0171`). SD1.5. |
|
||||
| `comfyui_build_status_effect_icon_workflow_py_ml` | `(effect, *, ui_style="game status icon, bold symbol, flat", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN icono de estado / buff-debuff (veneno, quemadura, congelación, escudo, regeneración, aturdimiento, velocidad, sangrado, maldición): **símbolo compacto** que se superpone al HUD para indicar un efecto activo, optimizado para **legibilidad a tamaño reducido** (16-32 px) → `{effect} status effect icon, {ui_style}, simple bold symbol, centered, readable at small size, plain background…` + LoRA estilo opcional + Rembg (alpha). **`size` por defecto menor (256, no 512)** porque se muestra pequeño; el negativo rechaza `intricate details/complex/cluttered` para no perder legibilidad. **DISTINTO de `item_icon` (objeto de inventario) y `ui_hud` (chrome grande de interfaz)**: aquí es un símbolo de estado. Barra coherente = mismo `ui_style`/`checkpoint`/`lora`, varía solo `effect` (color habla del tipo). El texto/contador lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `poison` 256×256 RGBA, símbolo verde flat centrado (`reports/0162`). SD1.5. |
|
||||
| `comfyui_build_skill_tree_node_workflow_py_ml` | `(skill, *, frame="hexagonal", state="unlocked", ui_style="fantasy skill tree node", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UN nodo de **árbol de habilidades / talentos** (RPG, ARPG, MOBA, roguelike): el icono de una `skill` **DENTRO de un marco** (`frame`: hexagonal/circular/diamond/shield) que la UI de progresión pinta en la rejilla, con variante de **estado** visual (`state`: `unlocked`=brillante/saturado, `locked`=gris/desaturado) → `{skill} skill icon inside a {frame} {ui_style} frame, {state} (…hint…), centered, plain background, game UI, skill tree talent node…` + LoRA estilo opcional + Rembg (alpha). El **marco** y el **estado** son la firma del asset. **DISTINTO de `item_icon` (objeto suelto sin marco), `status_effect_icon` (símbolo superpuesto sin marco) y `ui_hud` (chrome grande)**: aquí es el nodo enmarcado completo de la pantalla de talentos. Par de un mismo talento = mismo `skill`/`frame`/`ui_style`/`seed`, varía solo `state` (las dos caras de la rejilla). Árbol coherente = mismo `frame`/`ui_style`/`checkpoint`/`lora`, varía `skill`. El texto/coste lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `fireball` hexagonal unlocked 256×256 RGBA, nodo enmarcado brillante centrado (`reports/0173`). SD1.5. |
|
||||
| `comfyui_build_achievement_badge_workflow_py_ml` | `(badge, *, tier="gold", style="game achievement badge, ornate", checkpoint="dreamshaper_8…", size=256, transparent=True, seed=0, lora=None, …) -> dict` | UNA **insignia / medalla / logro** (achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro o un badge de rango que el panel de logros pinta al desbloquear un hito, con **`tier` metálico** (`bronze`/`silver`/`gold`/`platinum`/`diamond`) que distingue el grado → `{badge} achievement badge, {tier} tier (…hint metálico…), {style}, medal with ribbon, centered, plain background, game UI reward, trophy emblem…` + LoRA estilo opcional + Rembg (alpha). El **tier metálico** y la forma de **medalla/trofeo con cinta** son la firma del asset. **DISTINTO de `item_icon` (objeto de inventario suelto, sin tier ni cinta), `status_effect_icon` (símbolo de estado superpuesto sin marco) y `skill_tree_node` (nodo enmarcado de la rejilla de talentos con estado unlocked/locked)**: aquí es la insignia de logro/recompensa del panel de achievements. Familia de un mismo logro = mismo `badge`/`style`/`seed`, varía solo `tier` (los grados); set coherente = mismo `style`/`checkpoint`/`lora`, varía `badge`. El nombre/descripción/fecha lo pone el motor (negativo empuja a `no text`). Probado e2e en GPU con SD1.5 — `dragon slayer` tier gold seed 77 256×256 RGBA, medalla circular dorada con emblema centrado y fondo recortado a alpha (esquina α=0, centro α=254; `prompt_id 8b8b7ede`, `reports/0175`). SD1.5. |
|
||||
| `comfyui_build_card_art_workflow_py_ml` | `(subject, *, card_style="fantasy trading card art", checkpoint="juggernaut_xl_v11…", width=512, height=768, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración central de UNA carta coleccionable (TCG): criatura/personaje/hechizo en formato **vertical** de carta (`width<height`, ~512×768), composición centrada + iluminación dramática (`{subject}, {card_style}, dramatic lighting, detailed illustration, centered composition, full art…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`); si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el marco/título/stats los pone el motor/post (negativo rechaza `card frame/border/text/stats/UI`). Set coherente = mismo `card_style`/`checkpoint`/`lora`, varía solo `subject`. Probado e2e en GPU con SD1.5 (`reports/0153`); ⚠️ el path `hires=True` falla hoy por bug del builder `comfyui_build_hires_fix_workflow` (nodo `UltimateSDUpscale` pide `batch_size`) — usar `hires=False` hasta el fix. SD1.5/SDXL. |
|
||||
| `comfyui_build_enemy_creature_workflow_py_ml` | `(creature, *, variant=None, style="game creature, full body", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN enemigo/criatura de juego (goblin, esqueleto, slime, dragón, boss, elemental): figura de **cuerpo entero** centrada, fondo limpio recortable a alpha (`{variant} {creature}, {style}, full body, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `variant` (ice/fire/elite/corrupted…) se antepone a la criatura para generar la familia del MISMO enemigo (misma `creature`/`seed`/`style`, varía solo `variant`); bestiario coherente = mismo `style`/`checkpoint`/`lora`, varía solo `creature`. El negativo empuja a UNA criatura entera sin recorte. Probado e2e en GPU con SD1.5 (`reports/0154`). SD1.5. |
|
||||
| `comfyui_build_prop_object_workflow_py_ml` | `(prop, *, style="game prop, isometric or side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN prop/objeto de escenario (barril, cofre, antorcha, planta, mueble, roca, fuente, estatua): objeto inanimado aislado a **escala de escena y perspectiva de juego** (iso/lateral), centrado, fondo limpio recortable a alpha (`{prop}, {style}, game asset, single object, centered, plain background, scene prop, world object…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Objeto de MUNDO**, no icono plano de inventario (≠ `item_icon`, que es para una casilla de UI); este puebla el nivel. Atrezzo coherente = mismo `style`/`checkpoint`/`lora`, varía solo `prop`. El negativo excluye personas/criaturas (objeto inanimado). Probado e2e en GPU con SD1.5 (`reports/0155`). SD1.5. |
|
||||
| `comfyui_build_vehicle_mount_workflow_py_ml` | `(vehicle, *, view="side", style="game vehicle", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN vehículo/montura que el personaje **USA o CONDUCE** (caballo, dragón-montura, nave espacial, coche, barco, carro, grifo, mecha): el vehículo **COMPLETO** en vista lateral o isométrica, centrado, fondo limpio recortable a alpha, **SIN jinete/conductor** (`{vehicle}, {view} view, {style}, full vehicle, centered, plain background, game asset, no rider, empty…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). Se genera VACÍO (el negativo rechaza `person/rider/driver/passenger`) para que el motor componga al personaje encima. **DISTINTO de `enemy_creature` (sujeto a COMBATIR) y `prop_object` (atrezzo inanimado que decora)**: aquí el objeto se MONTA/USA; una montura viva que se cabalga (caballo, dragón) entra aquí, no en `enemy_creature`. `view` (side/iso) fija la geometría del parque móvil; set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `vehicle`. Probado e2e en GPU con SD1.5 — `armored war horse with saddle` side 512×512 RGBA, vehículo centrado recortado a alpha (centroide 0.55/0.54, 4 esquinas transparentes, `reports/0169`). SD1.5. |
|
||||
| `comfyui_build_topdown_sprite_workflow_py_ml` | `(subject, *, direction="south", style="top-down game sprite, RPG", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN sprite en **vista CENITAL (top-down)** estilo RPG clásico/roguelike (Zelda, juegos cenitales): personaje/objeto visto **desde arriba**, centrado, fondo limpio recortable a alpha (`{subject}, top-down view, overhead view, {direction} facing, {style}, centered, plain background, game asset…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). `direction` (south/north/east/west) para el sprite de movimiento: las 4 vistas del MISMO personaje = misma `subject`/`style`/`seed`, varía solo `direction` → montar con `comfyui_build_grid`. **DISTINTO de `sprite_sheet` (vista lateral/frontal de plataformas)**: el negativo por defecto rechaza side/front/3-4/isometric/perspective para forzar la cenital. Con SD1.5 sin LoRA sale picado alto; cenital estricto pide LoRA top-down + cfg alto. Probado e2e en GPU con SD1.5 (`reports/0156`). SD1.5. |
|
||||
| `comfyui_build_splash_art_workflow_py_ml` | `(scene, *, mood="epic, cinematic", checkpoint="juggernaut_xl_v11…", width=1024, height=576, hires=True, seed=0, lora=None, …) -> dict` | LA ilustración grande de UN splash / pantalla de carga / key art en formato **pantalla apaisado 16:9** (`width>height`, ~1024×576), composición cinematográfica (`{scene}, {mood}, key art, game splash screen, dramatic lighting, cinematic composition, wide shot, epic scale, atmospheric…`). `hires=True` → 2ª pasada de detalle (`comfyui_build_hires_fix_workflow`) para verse a pantalla completa; si no, txt2img + LoRA estilo opcional. Genera SOLO la ilustración — el título/logo/barra de carga los pone el motor/post (negativo rechaza `text/title/logo/UI/frame/watermark`), dejando aire para superponer el título. Set coherente = mismo `mood`/`checkpoint`/`lora`, varía solo `scene`. Probado e2e en GPU con SD1.5 + hires (1024×576 → 1536×864, 54s, `reports/0159`). SD1.5/SDXL. |
|
||||
| `comfyui_build_world_map_workflow_py_ml` | `(region, *, map_style="fantasy cartography, aged parchment", checkpoint="juggernaut_xl_v11…", width=768, height=768, hires=False, seed=0, lora=None, …) -> dict` | LA ilustración de la **pantalla de mapa** del juego: una lámina cartográfica en **vista cenital** de un continente/región/reino/mazmorra con aspecto de atlas fantasy (`map of {region}, {map_style}, top-down cartographic view, illustrated game world map, labeled regions, decorative border, compass rose, fantasy atlas, no people…`). **Cuadrado por defecto** (768×768; sube `width` para mundo apaisado, `height` para mazmorra en columna), `hires=False` por defecto (ponlo `True` para detalle fino de costas/relieve). Genera SOLO la ilustración — las marcas interactivas, los iconos pinchables, las rutas y el "estás aquí" los pone el motor SOBRE la lámina; la difusión dibuja labels/ornamentos **DECORATIVOS** pero NO garantiza ortografía ni posiciones usables como datos (el negativo rechaza `photo/3d render/perspective/character/person` para mantener la vista cenital plana). Atlas coherente = mismo `map_style`/`checkpoint`/`lora`, varía solo `region`. Probado e2e en GPU con SD1.5 — reino fantasy 768×768, lámina de pergamino con costas/montañas/regiones + borde ornamental + rosa de los vientos (`prompt_id bf4861fc`, `reports/0167`). SD1.5/SDXL. |
|
||||
| `comfyui_build_decal_overlay_workflow_py_ml` | `(decal, *, on_black=True, style="grunge decal, high detail", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UN decal/overlay con alpha para superponer sobre superficies/paredes/sprites con blend mode del motor (sangre, grietas, suciedad, óxido, quemaduras, salpicaduras, arañazos, musgo): textura **aislada sobre fondo PLANO** (`{decal}, {style}, single isolated decal, centered, on a solid pure black background, flat backdrop, sticker, no scenery, texture overlay, game asset…`) → txt2img cuadrado + LoRA estilo opcional. `on_black=True` (defecto) pensado para extraer alpha con **`comfyui_matting_luma_to_alpha`** (luma=alpha, conserva el falloff de translúcidos — la técnica gamedev correcta, ≠ recorte binario). **NO inyecta Rembg** (el matting es luma→alpha de disco, no un nodo): el SaveImage sale directo del VAEDecode. Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `decal`/`seed`. ⚠️ "grunge" en `style` arrastra fondo gris en SD1.5 → para fondo negro plano usar un `style` sin connotación de fondo + reroll de `seed`; luma Rec601 penaliza el rojo → para sangre roja pasar `luma_weights` con más peso al rojo. Probado e2e en GPU con SD1.5 (`reports/0160`). SD1.5. |
|
||||
| `comfyui_build_projectile_workflow_py_ml` | `(projectile, *, direction="right", glow=False, style="game projectile, side view", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN proyectil orientado (flecha, bala, bola de fuego, rayo, misil, hechizo): sprite pequeño con **orientación** (apunta a la derecha por defecto, ángulo 0 — el motor rota el sprite), aislado, listo para instanciar. **`glow` elige el camino a alpha**: `glow=False` (defecto) = proyectil SÓLIDO con silueta → `plain background` + **Rembg** (alpha por recorte, como `item_icon`/`topdown_sprite`); `glow=True` = brillante/mágico → `glowing, on black background` **sin Rembg** (recortaría el halo), insumo de **`comfyui_matting_luma_to_alpha`** que el caller aplica luego (como `vfx_spritesheet`/`decal_overlay`). `glow=True` ignora `transparent`/`rembg_model`; el negativo por defecto NO rechaza "black background". `direction` se inserta como `pointing {direction}` (`""`/None = sin orientación). Set coherente = mismo `style`/`checkpoint`/`lora`, varía solo `projectile`/`seed`. Probado e2e en GPU con SD1.5 — fireball glow sobre negro + luma→alpha RGBA (`reports/0161`). SD1.5. |
|
||||
| `comfyui_build_structure_workflow_py_ml` | `(structure, *, view="isometric", style="game building", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN edificio/estructura de escenario (casa, torre, castillo, tienda, posada, ruina, muralla, puente, templo, faro): UN **building COMPLETO** y centrado a perspectiva de juego (`{view} view`, iso por defecto), fondo limpio recortable a alpha (`{structure}, {view} view, {style}, full building, complete structure, single building, centered, plain background, game asset, architecture…`) → txt2img cuadrado + LoRA estilo/iso opcional + Rembg (alpha). **EDIFICACIÓN grande que ocupa varios tiles y define el escenario**, no un objeto pequeño suelto (≠ `prop_object`, que es atrezzo que se deja sobre un tile); el negativo rechaza `small object / single item / prop / furniture`. `view` fija la perspectiva del mapa (iso/side/front/top-down/¾); LoRA iso fija mejor el ángulo 2:1. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `structure`. Probado e2e en GPU con SD1.5 — `medieval blacksmith shop` iso 512×512 RGBA, edificio centrado recortado a alpha (centroide 0.54/0.53, `reports/0164`). SD1.5. |
|
||||
| `comfyui_build_foliage_set_workflow_py_ml` | `(plant, *, view="side", style="game foliage, stylized", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UN elemento de **vegetación/foliage** de escenario (árbol, arbusto, hierba alta, flores, helecho, hongo, cactus, tronco caído, juncos, hiedra): UN elemento de **naturaleza ORGÁNICA AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{plant}, {view} view, {style}, single plant element, centered, plain background, game nature asset, natural vegetation, organic, isolated plant…`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Vegetación que viste el terreno**, distinta del **objeto MANUFACTURADO** suelto (≠ `prop_object`: barril/cofre/mueble) y del **EDIFICIO** (≠ `structure`: casa/torre); el negativo rechaza `building / manmade object / barrel / furniture / person` y `multiple plants / dense forest / jungle / landscape` (UN elemento, no un bosque) + `pot / planter / vase` (planta en maceta = `prop_object`). Recorte por **Rembg** (planta opaca de silueta definida), no luma→alpha. Set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `plant`. ⚠️ **dos gotchas reales SD1.5+Rembg**: (1) **plantas grandes (árbol) tienden a PAISAJE** (cielo+campo) en lugar de fondo plano → re-roll de seeds buscando fondo uniforme (`comfyui_batch_generate`); (2) **follaje verde claro sobre fondo claro → Rembg se come las hojas** y deja solo tronco/ramas → preferir elementos de **silueta compacta y color saturado** (hongo, arbusto denso) o `transparent=False` + matting manual. Probado e2e en GPU con SD1.5 — golden `a glowing mushroom` seed 7 512×512 RGBA, hongo centrado recortado a alpha limpio (centroide 0.51/0.58, opaco 19%, `prompt_id 8fb65a51`); evidencia del gotcha del roble en `reports/0170`. SD1.5. |
|
||||
| `comfyui_build_trap_hazard_workflow_py_ml` | `(hazard, *, view="side", style="game hazard trap", checkpoint="dreamshaper_8…", size=512, transparent=True, seed=0, lora=None, …) -> dict` | UNA **trampa/peligro JUGABLE** de nivel (pinchos del suelo, sierra giratoria, foso de lava, placa de presión, columna de llamas, trampa de flechas, charco ácido, descarga eléctrica, prensa, estaca cayendo): UN objeto de **peligro AISLADO** y centrado a perspectiva de juego (`{view} view`, `side` por defecto), fondo limpio recortable a alpha (`{hazard}, {view} view, {style}, single hazard object, trap, dangerous, centered, plain background, game asset, high detail`) → txt2img cuadrado + LoRA estilo opcional + Rembg (alpha). **Peligro al que el motor asigna hitbox de daño + estado activo/inactivo**, distinto del **objeto INERTE de decoración** (≠ `prop_object`: barril/cofre que solo ambienta) y del **enemigo VIVO** (≠ `enemy_creature`); el negativo rechaza `character / person / creature / multiple objects` para que salga el mecanismo, no un enemigo ni una escena. Recorte por **Rembg** (trampa sólida de silueta definida: pinchos/sierra/placa); ⚠️ para hazards **puramente etéreos** (columna de llamas, arco eléctrico, gas) usar `transparent=False` + `comfyui_matting_luma_to_alpha` (conserva el falloff translúcido para blend aditivo), no Rembg. `view` fija la perspectiva del nivel (side/top-down/iso); set coherente = mismo `view`/`style`/`checkpoint`/`lora`, varía solo `hazard`. Probado e2e en GPU con SD1.5 — `spiked floor trap` side seed 7 512×512 RGBA, mecanismo de peligro centrado recortado a alpha (alpha extrema 0–255, fondo transparente real, `prompt_id ab1b1560`, `reports/0174`). SD1.5. |
|
||||
| `comfyui_build_particle_texture_workflow_py_ml` | `(particle, *, soft=True, style="particle texture, soft glow", checkpoint="dreamshaper_8…", size=256, seed=0, lora=None, …) -> dict` | UNA textura de **partícula individual** reutilizable (chispa, humo, polvo, destello/flare, gota, copo, hoja, círculo de energía) — el "ladrillo" que el sistema de partículas del motor (Godot `GPUParticles2D`, Unity VFX Graph) instancia a **miles** y anima (spawn/fade/color over lifetime). Aislada y centrada **sobre fondo NEGRO** (`{particle} particle, {style}, isolated on pure black background, <soft|sharp> edges, single element, for game particle system…`) → txt2img cuadrado + LoRA estilo opcional. **`soft` controla el borde**: `soft=True` (defecto) → `soft glow, feathered edges` (humo/destello/gota); `soft=False` → `crisp sharp edges, high contrast` (chispa/copo/hoja). **NO inyecta Rembg** (rompería el falloff translúcido): insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, additive blend en el motor). **`size` por defecto pequeño (256)** porque se replica a miles. **DISTINTO de `vfx_spritesheet`** (ese es la SECUENCIA animada de un efecto; esto es UNA textura estática reutilizable) **y de `decal_overlay`** (ése es una mancha de desgaste estática para superponer; éste es un emisor de partículas). ⚠️ el `style` por defecto trae "soft glow" → si pides `soft=False` para algo nítido, usa un `style` sin connotación suave. Probado e2e en GPU con SD1.5 — `spark` 256×256 sobre negro plano (dark 85%) + luma→alpha RGBA con falloff preservado (`reports/0163`). SD1.5. |
|
||||
| `comfyui_build_weather_overlay_workflow_py_ml` | `(weather, *, on_black=True, style="weather overlay, atmospheric", checkpoint="dreamshaper_8…", width=1024, height=576, seed=0, lora=None, …) -> dict` | UNA **capa de clima/atmósfera a PANTALLA COMPLETA** que cubre toda la vista del jugador y se superpone sobre la escena con blend del motor (lluvia, niebla, nieve, rayos de sol/god rays, polvo, viñeta de tormenta): cobertura **uniforme de borde a borde**, generada **APAISADA a resolución de pantalla** (16:9, 1024×576 por defecto — `width>height`, NO cuadrado) (`{weather} overlay, {style}, full screen atmospheric layer, <particles/streaks on pure black background \| translucent layer>, seamless full screen coverage, edge to edge, game VFX…`) → txt2img apaisado + LoRA estilo opcional. **`on_black` elige el modo de blend**: `on_black=True` (defecto) = clima BRILLANTE sobre **NEGRO puro** (estrías de lluvia, copos, haces de luz, motas), **sin Rembg**, insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo/screen** — el negro desaparece, el clima brilla sobre la escena); `on_black=False` = **película TRANSLÚCIDA** semi-transparente (niebla densa, tinte de tormenta) para blend multiply/overlay o alpha global. El negativo rechaza `solid object/single subject/character/building/landscape scene/horizon line/frame` (cobertura total, NO un sujeto centrado) + (si `on_black`) `blue sky/gray/white background` para forzar negro plano. **DISTINTO de `decal_overlay`** (ése es una mancha LOCALIZADA que se pega en un punto de una superficie) **y de `vfx_spritesheet`** (ése es la SECUENCIA animada de UN efecto puntual): la capa de clima es UNA película estática de cobertura full-screen que el motor anima por scroll/loop/shader. Set coherente = mismo `style`/`checkpoint`/`lora`, varía `weather`/`seed`. ⚠️ algunos climas (lluvia/niebla) pintan cielo azul de fondo en SD1.5 aunque pidas negro → subir `cfg` + re-roll de `seed`; climas brillantes-sobre-negro (god rays, snow, sparks) salen más limpios que los difusos (fog). luma Rec601 penaliza el azul → para lluvia azulada ajustar `luma_weights`/`gamma` en el matting. Probado e2e en GPU con SD1.5 — `heavy rain` on_black seed 11 **1024×576** (16:9 exacto), estrías de lluvia brillantes sobre **negro plano** (esquinas luma 0.00, dark 89.6%, lluvia 1.4% brillante) apto luma→alpha aditivo (`prompt_id 5d2300d1`, `reports/0176`). SD1.5/SDXL. |
|
||||
| `comfyui_build_rune_glyph_workflow_py_ml` | `(glyph, *, glow=True, style="arcane glowing rune", checkpoint="dreamshaper_8…", size=512, seed=0, lora=None, …) -> dict` | UNA **runa / glifo / sigilo mágico** (glifos rúnicos, círculos mágicos, sigilos de invocación, inscripciones brillantes) para hechizos, portales, marcas de conjuro y efectos de magia: símbolo arcano **aislado** sobre fondo uniforme (`{glyph}, {style}, magic symbol, single isolated glyph, centered, glowing on a solid pure black background, occult sigil, arcane inscription, no scenery, game asset…`) → txt2img cuadrado + LoRA estilo opcional. **`glow` elige el camino a alpha**: `glow=True` (defecto) = runa BRILLANTE sobre **NEGRO puro**, **sin Rembg** (recortaría el halo del resplandor), insumo de **`comfyui_matting_luma_to_alpha`** (luma=alpha, **blend aditivo** en el motor — conserva el glow); `glow=False` = runa MATE/grabada sobre fondo plano (el negativo rechaza `glow/neon/bloom`), recorte/inversión por el caller. El negativo rechaza `realistic text/readable words/latin alphabet` (un glifo arcano, **no letras reales**) + fondo texturizado/niebla. **DISTINTO de `status_effect_icon`** (símbolo SÓLIDO de UI, recorte Rembg, legible a 16-32 px en el HUD): la runa es una marca translúcida que **emite luz** e se inscribe en el mundo. Grimorio coherente = mismo `style`/`checkpoint`/`lora`, varía `glyph`/`seed`. ⚠️ luma Rec601 penaliza el rojo → para runas rojas (sigilo demoníaco) pasar `luma_weights` con más peso al rojo + subir `gamma`; runas blancas/azules/doradas van con pesos por defecto. Probado e2e en GPU con SD1.5 — `circular summoning rune` glow seed 11 512×512, círculo de invocación brillante sobre **negro puro** (esquinas luma 0.00, dark 83%, runa 3.4% brillante, max 255) apto luma→alpha (`prompt_id 701d149a`, `reports/0172`). SD1.5. |
|
||||
| `comfyui_build_title_lettering_workflow_py_ml` | `(text, *, letter_style="epic fantasy metallic", checkpoint="juggernaut_xl_v11…", width=1024, height=512, transparent=True, seed=0, lora=None, …) -> dict` | EL texto/logo de **título** de un juego (el nombre del juego o una palabra) renderizado con un **tratamiento de lettering** (metálico, tallado en fuego/piedra/madera, neón, cristal, oro), formato **apaisado** (`width>height`, 1024×512 por defecto), fondo plano recortable a alpha (`the word "{text}" as a game logo, {letter_style} lettering, stylized typography, centered, plain background…`) → txt2img apaisado + LoRA estilo opcional + Rembg (alpha). El **negativo NO rechaza texto** (el lettering es el sujeto) y empuja contra el ruido textual (`extra letters/jumbled text/deformed letters`). El VALOR es el ESTILO del lettering, **NO** la fidelidad tipográfica: ⚠️ la difusión renderiza texto de forma imperfecta — letras de más, deformadas o mal escritas; mitigar con palabras CORTAS en MAYÚSCULA, **re-roll de seeds** (`comfyui_batch_generate`), SDXL > SD1.5 para texto, o pintar el texto real con una fuente en el motor. **Una palabra que es un objeto concreto (DRAGON) → el modelo dibuja el objeto, no las letras** — usar palabras abstractas o reforzar `letter_style`. Marca coherente = mismo `letter_style`/`checkpoint`/`lora`, varía solo `text`. Recorte por **Rembg** (logo sólido), no luma→alpha. Probado e2e en GPU: `DRAGON`/`fire engraved` SD1.5 1024×512 → ilustró dragones rojos (alpha OK, confirma el gotcha de palabra-objeto, `prompt_id 6f3920b7`); `AETHER`/`epic fantasy metallic` SDXL 768×384 → **logo de texto metálico dorado** legible con ortografía imperfecta + alpha (`prompt_id 2a7fe8ba`, `reports/0165`). SD1.5/SDXL. |
|
||||
|
||||
## Animación de assets (vídeo) — caminos validados e2e
|
||||
|
||||
Tres vías para que un asset 2D se **mueva** (loop de VFX, sprite animado, fondo con
|
||||
movimiento), todas cabiendo en 8GB **con la GPU vacía** (cierra el juego antes — el
|
||||
vídeo NO convive con un juego AAA en VRAM). Los builders son del grupo hermano `comfyui`
|
||||
(dominio `ml`); aquí se documenta su **uso gamedev**. Reutilizan el round-trip canónico
|
||||
`build → comfyui_submit_workflow → (sondear /history) → comfyui_fetch_output_video`.
|
||||
|
||||
| Vía | Builder | Para qué (gamedev) | Validado |
|
||||
|---|---|---|---|
|
||||
| **txt2video (LTX)** | `comfyui_build_video_workflow(prompt, model='ltx', width=512, height=320, num_frames=25, fps=12)` | **Loop de elemento desde texto**: portal, antorcha, agua, humo, magia. Sale `.mp4`. Modelo LTX-Video 2B v0.9.5 (`ltx-video-2b-v0.9.5.safetensors` + text encoder `t5xxl_fp8`). | e2e GPU: portal mágico 512×320, **25 frames**, 2.08s, pico **7717 MiB / 8192**, `prompt_id 54eda033`, `reports/0186`. |
|
||||
| **txt2video (Wan)** | `comfyui_build_video_workflow(prompt, model='wan', …)` | Igual que LTX pero con Wan2.1 T2V 1.3B (`wan2.1_t2v_1.3B_fp16` + `umt5_xxl_fp8` + `wan_2.1_vae`). Enlazado y visible en `/object_info`. | Enlace verificado en `reports/0186`; clip no generado aún (LTX cubrió el golden). |
|
||||
| **img2vid (SVD)** | `comfyui_build_img2vid_workflow('sprite.png', width=512, height=512, video_frames=14, motion_bucket_id=127)` | **Animar un sprite/fondo YA generado**: copia la imagen a `~/ComfyUI/input/`, SVD la condiciona por CLIP_VISION (no usa prompt de texto) y la pone en movimiento. Sale `.webp` animado. | e2e GPU: `enemy_creature` (del pack) → 512×512 RGBA **14 frames** animado, pico **7463 MiB / 8192**, `prompt_id 5b501d03`, `reports/0186`. |
|
||||
| **spritesheet (AnimateDiff)** | `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8, closed_loop=True)` | N frames de un VFX 2D en bucle seamless sobre negro (insumo de luma→alpha + montaje de spritesheet). | e2e GPU previos (`reports/0140`/`0143`); 8GB: usar ≤8f@512² o bajar resolución (16f@512² revienta). |
|
||||
|
||||
**Límites VRAM (RTX 3070 8GB, GPU vacía):** LTX 512×320@25f → 7717 MiB; SVD 512×512@14f →
|
||||
7463 MiB. Margen estrecho (~0.5 GB): con un juego AAA abierto (~2.7 GB) **ningún** camino
|
||||
de vídeo cabe → cerrar el juego o ir a frames/res mínimos. La generación de **imagen**
|
||||
estática sí convive con el juego. `comfyui_wait_result` **lanza** `TimeoutError` al
|
||||
expirar (envolver en try/except); SVD es lento (>10 min para 14f en lowvram), pero el job
|
||||
completa en GPU aunque el script de orquestación expire — recuperar el output sondeando
|
||||
`/history` por `prompt_id`. Para transparencia, post-procesar los frames a alpha
|
||||
(luma→alpha o rembg por frame).
|
||||
|
||||
## Builders de transformación (`gamedev-2d`, puros — parten de una imagen/dibujo de entrada)
|
||||
|
||||
A diferencia de los builders de **generación** de arriba (parten de TEXTO, txt2img desde
|
||||
ruido), estos parten de una **imagen de entrada** y la transforman. Cuatro sub-ejes:
|
||||
|
||||
- **img2img** (`asset_variant`): parte de un asset **ya pintado**; el KSampler arranca del
|
||||
latente de la imagen base (LoadImage → VAEEncode), no de ruido, así que con `denoise` medio
|
||||
conserva la estructura mientras el prompt reescribe material/paleta/tier. Reescribe **todo** el
|
||||
asset conservando forma **y** color del original.
|
||||
- **sketch→ControlNet** (`sprite_from_sketch`): parte del **dibujo tosco** del dev (boceto,
|
||||
lineart, garabato); es `txt2img` (arranca de ruido) pero condicionado por un ControlNet atado
|
||||
al mapa de líneas del dibujo. Conserva solo la **forma**; la IA pone material/color/acabado.
|
||||
- **inpaint** (`inpaint_asset`): parte de un asset **ya pintado** + una **máscara** que marca qué
|
||||
región editar (blanco) y cuál conservar (negro); el sampler regenera **solo** la zona enmascarada
|
||||
dejando el resto del pixel intacto. Cambia **una parte** (arma, casco, escudo, reparación), no el
|
||||
asset entero.
|
||||
- **outpaint** (`outpaint_asset`): parte de un asset **ya pintado** y **agranda el lienzo** por uno o
|
||||
varios lados; el nodo `ImagePadForOutpaint` extiende el canvas **y genera** la máscara feathered de
|
||||
la franja nueva (no la recibe el caller), y el sampler genera ahí contenido coherente. Cambia el
|
||||
**tamaño** del asset (recortar/extender un fondo o parallax a otra resolución/aspect), no lo de dentro.
|
||||
- **multi-vista 3D / 2.5D** (`directional_sprite`): parte del sprite **frontal** de un personaje y lo
|
||||
**rota en 3D** (SV3D turntable u Stable Zero123 órbita) para producir N vistas direccionales del MISMO
|
||||
personaje (8-way N/NE/E/SE/S/SW/W/NW o 4-way). A diferencia de `sprite_sheet` (re-poza con OpenPose 2D,
|
||||
re-dibuja la silueta → identidad inconsistente entre ángulos), aquí la difusión 3D gira la figura sobre
|
||||
su eje, así casco/arma/paleta son los mismos en cada dirección (**consistencia rotacional**). Cambia el
|
||||
**ángulo de cámara**, no la pose ni el material.
|
||||
|
||||
Cubren el eje que el critic de generación (`reports/0178`) no exploró: derivar de un asset o
|
||||
del dibujo del dev, no inventar un tipo nuevo desde texto.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_build_asset_variant_workflow_py_ml` | `(input_image, variant, *, checkpoint="dreamshaper_8…", denoise=0.5, style="game asset", size=512, seed=0, lora=None, …) -> dict` | UNA **variante coherente de un asset 2D ya generado** (img2img): parte del sprite/icono que existe en `input_image` y produce su versión de **otro material/paleta/tier/estado** (`ice element`, `fire element`, `battle-damaged`, `golden tier 2`, `corrupted`) manteniendo **silueta, pose y composición** del original. Compone `comfyui_build_img2img_workflow` (LoadImage → VAEEncode → KSampler con `denoise`) + `comfyui_inject_lora` (estilo opcional) + `ImageScale` opcional (`size` normaliza la base a size×size; `size=None` preserva las dimensiones exactas sin deformar). El prompt es `{variant}, {style}, same composition, same pose, same silhouette, …`. **`denoise` es la palanca**: ~0.3 invisible, **0.45-0.6 recomendado** (cambia material/paleta, conserva forma), ~0.8 deriva la pose y se acerca a txt2img. Set de variantes del MISMO asset = mismo `input_image`/`style`/`seed`, varía solo `variant`. **DISTINTO de los builders txt2img** (`enemy_creature`, `item_icon`…): esos generan un tipo desde cero; éste transforma uno concreto. **NO inyecta Rembg** (img2img preserva el fondo/alpha del original según la base). ⚠️ la imagen base debe existir en `input/` del server (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); asset NO cuadrado + `size` fijo + `crop="disabled"` deforma → `size=None` o `crop="center"`. Probado e2e en GPU con SD1.5 — variante `ice element, frozen` del goblin `enemy_creature_00001_.png` denoise 0.5 seed 7 512×512 (`prompt_id 5e4a5d3d`): silueta conservada (luminance corr 0.63) + paleta a frío (blueness B−R −1.6→+1.9), `reports/0181`. SD1.5. |
|
||||
| `comfyui_build_sprite_from_sketch_workflow_py_ml` | `(sketch_image, subject, *, control_type="lineart", checkpoint="dreamshaper_8…", style="game asset, clean, centered", strength=0.8, size=512, seed=0, lora=None, preprocess=True, controlnet_name=None, …) -> dict` | UN **sprite pintado a partir del BOCETO del dev**, guiado por **ControlNet** (sub-eje sketch→ControlNet, **NO img2img**). Recibe el dibujo tosco que existe en `sketch_image` (boceto/lineart/garabato) + `subject` (qué es), y genera un sprite en estilo de juego que **conserva la forma dibujada**: el dev marca la silueta, la IA pone material/color/acabado. Mecanismo: `txt2img` base (ruido, `EmptyLatentImage`, `denoise 1.0`) cuyo positivo pasa por `ControlNetApply` atado al mapa de líneas del boceto. `control_type` elige el **preprocesador** (`LineArtPreprocessor` / `ScribblePreprocessor` / `CannyEdgePreprocessor`, interpuesto entre el boceto y el ControlNet por un helper) y, por defecto, el **modelo CN emparejado**. Compone `comfyui_build_txt2img_workflow` + `comfyui_inject_controlnet` + `comfyui_inject_lora` (estilo opcional). **`strength` es la palanca**: 0 = ignora el dibujo (txt2img puro), ~0.8 recomendado (respeta forma dejando limpiar a la IA), 1.0 = se ciñe estricto. **DISTINTO de `asset_variant`** (img2img conserva forma+color de una imagen ya pintada) y de los txt2img (`enemy_creature`…, inventan la forma desde texto): éste conserva **solo la forma** del dibujo. ⚠️ el boceto debe existir en `input/` (subir con `POST /upload/image`); pura, no valida (usar `comfyui_validate_workflow` antes de enviar); `preprocess=False` solo si el sketch ya es un lineart limpio. **GOTCHA del server 8GB: solo `canny`/`depth`/`openpose` SD1.5 instalados** — para `lineart`/`scribble` pasa `controlnet_name="control_v11p_sd15_canny_fp16.safetensors"` u usa `control_type="canny"` (out-of-the-box); pendiente humano descargar `control_v11p_sd15_lineart_fp16`/`scribble`. Probado e2e en GPU con SD1.5 — boceto del goblin `enemy_creature_00001_.png` → `CannyEdgePreprocessor` → ControlNet canny, `subject="dark fantasy goblin warrior"` strength 0.85 seed 123 512×512 (`prompt_id ea6fc372`): pose/orejas/hombrera/lanza dentada/espada del dibujo conservadas, repintado en estilo de juego, `reports/0182`. SD1.5. |
|
||||
| `comfyui_build_inpaint_asset_workflow_py_ml` | `(input_image, mask_image, prompt, *, checkpoint="dreamshaper_8…", denoise=1.0, style="game asset", grow_mask=6, size=None, seed=0, lora=None, mode="vae_encode", …) -> dict` | EDITA **solo una región** de un asset 2D ya pintado (**inpaint**, sub-eje propio). Recibe el asset en `input_image` + una **máscara** `mask_image` (BLANCO = editar, NEGRO = conservar) + `prompt` de qué poner ahí, y repinta **únicamente** la zona enmascarada dejando el resto del sprite intacto (cambiar/añadir un arma, quitar un casco, poner un escudo, reparar una zona dañada). Mecanismo (`mode="vae_encode"`): `VAEEncodeForInpaint` codifica el latente respetando la máscara y dilata su borde `grow_mask` px para difuminar la costura; `KSampler` (`denoise` alto) regenera solo esa región con `{prompt}, {style}, seamless blend…`. Compone `comfyui_build_inpaint_workflow` (base) + `comfyui_inject_lora` (estilo opcional); `size` escala imagen **Y** máscara de forma consistente (escalar solo una las desalinea). **`grow_mask` es la palanca de costura** (6-10 px difumina el borde lo/nuevo); `denoise` 1.0 reescribe entero, ~0.5-0.7 repara suave. **DISTINTO de `asset_variant`** (img2img reescribe TODO el asset) y de `sprite_from_sketch` (ControlNet parte de un dibujo de líneas para un sprite nuevo): éste edita **un trozo** delimitado por la máscara. **ERROR-PATH**: si el server no expone `VAEEncodeForInpaint`, pasar `mode="noise_mask"` → degrada a `VAEEncode` + `SetLatentNoiseMask` (+ `GrowMask`); `mask_image` vacío lanza `ValueError`. ⚠️ asset y máscara deben existir en `input/` (subir con `POST /upload/image`) y compartir resolución (o usar `size`); `ImageScale` aquí NO ofrece `lanczos` (válidos `bilinear`/`nearest-exact`/`area`/`bicubic`); pura, no valida. Probado e2e en GPU con SD1.5 — máscara circular (R70) sobre la mano del goblin `enemy_creature_00001_.png`, `prompt="a glowing blue magic orb"` grow_mask 8 denoise 1.0 seed 7 (`prompt_id 88b52c66`): orbe azul en la región, **resto idéntico** (diff medio dentro 40.3 vs fuera 1.97 → ratio 20.4×; 44.6% px cambiados dentro vs 1.7% fuera), `reports/0183`. SD1.5. |
|
||||
| `comfyui_build_directional_sprite_workflow_py_ml` | `(input_image, *, directions=8, model="sv3d", elevation=0.0, size=None, orbit_frames=None, seed=0, ckpt=None, …) -> dict` | UN **sprite MULTI-DIRECCIONAL** del MISMO personaje rotado en 3D (**multi-vista 2.5D**, sub-eje propio): parte de la imagen **frontal** del personaje (fondo limpio, en `input/`) y construye el workflow que genera N vistas direccionales CONSISTENTES (8-way N/NE/E/SE/S/SW/W/NW o 4-way) para top-down/iso/shooter 8-way. `model="sv3d"` (default) = `SV3D_Conditioning` produce un **orbit turntable** de N frames equiespaciados en 360° en una pasada (mejor consistencia, `sv3d_p.safetensors`, nativo 576²); `model="zero123"` = `StableZero123_Conditioning_Batched` da un **batch** de N vistas por azimuth (fallback menor VRAM, `stable_zero123.ckpt`, nativo 256²). `elevation` (~15-30) da picado para cámara cenital; `orbit_frames` (SOLO sv3d) densifica el orbit (21 nativo) para submuestrear; el módulo expone `directional_sprite_view_order(directions)` (frame i = dirección i). **DISTINTO de `sprite_sheet`** (OpenPose 2D re-poza la silueta → identidad inconsistente): aquí la difusión 3D ROTA la figura sobre su eje → casco/arma/paleta idénticos en cada dirección (rotación 3D real, no re-dibujo). Construye, NO genera (el coste GPU es el `submit`); **pura, no valida** (la imagen frontal debe existir en `input/`). Hermana **pura** de `comfyui_generate_views_from_image` (orquestador impuro para recon 3D, 4 cardinales). ⚠️ VRAM RTX 3070 8GB: SV3D es modelo de vídeo, pesa — 8 frames@576² → pico **7145 MiB**; limpiar GPU antes (`POST /free`); OOM → baja `size`/`directions` o cae a zero123, NO matar procesos; `comfyui_wait_result` lanza `TimeoutError` pero el job completa (sondear `/history`). Probado e2e en GPU con SV3D — goblin `enemy_creature_00001_.png` (compuesto sobre blanco 576²) → 8 direcciones elevation 15 seed 7, **8 frames** 576² en 75 s, consistencia rotacional medida (MAE adyacentes 27 < frente↔espalda 29.6, spread de paleta 3.83 = mismo personaje en las 8 vistas; `prompt_id 8b9f75de`, `reports/0187`). SV3D/Zero123. |
|
||||
| `comfyui_build_outpaint_asset_workflow_py_ml` | `(input_image, prompt, *, left=0, right=0, top=0, bottom=0, feather=40, checkpoint="dreamshaper_8…", denoise=1.0, style="game background", grow_mask=0, seed=0, lora=None, …) -> dict` | EXTIENDE **el lienzo** de un asset 2D ya pintado (**outpaint**, sub-eje propio). Recibe el asset en `input_image` + cuánto extender por cada lado (`left`/`right`/`top`/`bottom` px) + `prompt` de qué generar fuera de los bordes, y **agranda el canvas** generando contenido coherente con el original más allá de sus bordes (recortar/extender un fondo, parallax, card_art o splash a otra resolución/aspect ratio). Mecanismo: el nodo nativo `ImagePadForOutpaint` amplía el lienzo y **EMITE** a la vez la imagen extendida **y** la máscara feathered de la franja nueva (la genera el grafo, **NO** la recibe el caller); `VAEEncodeForInpaint` codifica respetando esa máscara y `KSampler` (`denoise` alto) genera lo nuevo con `{prompt}, {style}, seamless extension…`. Compone `comfyui_build_inpaint_workflow` (base; su `LoadImageMask` se elimina y `VAEEncodeForInpaint` se reconecta a las dos salidas del pad) + `comfyui_inject_lora` (estilo opcional). **`feather` difumina la costura** (40 px por defecto, no debe pasarse de la extensión); `grow_mask` (0 por defecto) dilata adicionalmente el borde si aparece costura dura. **DISTINTO de `inpaint_asset`**: éste **no recibe máscara** (la genera el pad) y cambia el **tamaño** del asset extendiendo hacia fuera, mientras inpaint edita una región **interior** con máscara externa del mismo tamaño. **ERROR-PATH**: `input_image`/`prompt` vacíos o las cuatro extensiones en 0 tras redondear (`left=3`→0) lanzan `ValueError`; si el server no expone `ImagePadForOutpaint`, consultar `/object_info`. ⚠️ el asset debe existir en `input/` (subir con `POST /upload/image`); las extensiones se redondean a múltiplo de 8 (`250→248`); pura, no valida. Probado e2e en GPU con SD1.5 — fondo `seamless_00004_.png` 512×512 extendido `right=256` feather 40 denoise 1.0 seed 7 (`prompt_id aa33de05`): canvas **512→768×512** (+256), original conservado (diff medio 7.2 lejos del borde) + franja nueva con contenido coherente (std 28.9, dist de paleta 28.6), `reports/0185`. SD1.5. |
|
||||
|
||||
## Funciones de post-proceso y puente (`gamedev-2d`, CPU)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_pixelize_image_py_ml` | `(src, dst, *, downscale=8, colors=16, palette=None, dither=False, upscale_back=True) -> dict` | Pixel-perfect: downscale nearest + cuantización a N colores o paleta fija (game-boy/pico-8/nes). Fase 2 pixelart. Impura (I/O). |
|
||||
| `comfyui_matting_luma_to_alpha_py_ml` | `(image_path, *, out_path=None, gamma=1.0, black_point=0.0, premultiply=False, luma_weights=(.299,.587,.114)) -> dict` | Frame VFX sobre negro -> RGBA usando luminancia como alpha (translúcidos con additive blend). Impura (I/O). |
|
||||
| `comfyui_export_asset_to_godot_py_pipelines` | `(asset_path, kind, godot_project, *, name=None, reimport=True, godot_bin=None) -> dict` | Copia el asset a `res://assets/<dir>/` por `kind` + escribe `.import` + filtro Nearest si pixelart + reimport headless. Pipeline impuro. |
|
||||
| `godot_map_asset_dir_py_core` | `(kind) -> str` | Mapea `kind` -> subcarpeta de `res://assets/`. Pura. |
|
||||
| `godot_clean_asset_name_py_core` | `(filename, *, override=None) -> str` | Normaliza el nombre `<prefijo>_NNNNN_.<ext>` a snake_case seguro para `res://`. Pura. |
|
||||
|
||||
## Estilos (style presets) — calidad por ESTILO reutilizable
|
||||
|
||||
Un *style preset* es la receta curada de un look visual que se aplica a **TODOS** los
|
||||
assets de un juego de una vez ("todo en Game Boy", "estilo Ghibli", "pixel-art retro").
|
||||
En vez de repetir a mano `style`/`checkpoint`/`lora`/`negative` + post-proceso en cada
|
||||
builder, el preset los empaqueta como DATOS puros y el helper los traduce a los kwargs de
|
||||
cualquier builder de sujeto (item_icon, enemy_creature, prop_object, …) o del pipeline
|
||||
`comfyui_generate_asset_pack_oneshot`. Diseño (issue 0087): función pura de presets +
|
||||
helper de aplicación (NO un pipeline monolítico) — máxima composabilidad, sin acoplar
|
||||
firmas. Extensible: añadir un estilo = una entrada en `_PRESETS`.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_get_gamedev_style_preset_py_ml` | `(name=None) -> dict` | Devuelve la receta de un STYLE PRESET curado o el catálogo si `name=None`. Receta = `{subject_prefix, subject_suffix, style, negative, checkpoint, lora, lora_strength, size, transparent, post, notes}`. Pura, copias profundas. **6 estilos**: **gameboy** (sin LoRA → prompt + post `pixelize` paleta `game-boy` 4 tonos verde), **ghibli** (degrada a `SD15_watercolor_style` gratis instalado + prompt; no hay LoRA Ghibli dedicado ni se descargó nada gated), **pixel-art-retro** (reutiliza `SDXL_pixel-art` SDXL ya instalado → checkpoint `juggernaut_xl_v11` + size 768 + post `pixelize` 16 colores), **cyberpunk-neon** (prompt puro SD1.5, glow magenta/cyan, sin post), **low-poly-flat** (prompt puro SD1.5, facetas/flat shading PS1, sin post, transparent), **cartoon-cel-shaded** (LoRA `SD15_anime_style_box` 0.7 + prompt cel-shaded, sin post, transparent). Extensible: añadir un estilo = una entrada en `_PRESETS`. |
|
||||
| `comfyui_apply_style_preset_py_ml` | `(preset, subject, *, style=None, negative=None) -> dict` | Traduce un preset + un `subject` a `{name, subject (con prefijo/sufijo), builder_kwargs={style,checkpoint,lora,lora_strength,negative}, size, transparent, post}`. Los `builder_kwargs` hacen `**spread` directo en cualquier builder de sujeto; `size`/`transparent` van aparte (recomendaciones); el caller aplica `post["pixelize"]` al PNG si existe. Pura, no muta el preset; `negative` se mergea (no reemplaza). |
|
||||
|
||||
**Ejemplo canónico (mismo subject, look del juego entero):**
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_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 ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
|
||||
preset = comfyui_get_gamedev_style_preset("gameboy") # o "ghibli" / "pixel-art-retro"
|
||||
ap = comfyui_apply_style_preset(preset, "knight character")
|
||||
wf = comfyui_build_enemy_creature_workflow(ap["subject"], size=ap["size"],
|
||||
transparent=ap["transparent"], seed=7, **ap["builder_kwargs"])
|
||||
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||
outs = comfyui_wait_result(pid, timeout=500)
|
||||
fn = next(i["filename"] for o in outs.values() for i in o.get("images", []))
|
||||
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["path"]
|
||||
if ap["post"].get("pixelize"): # gameboy/pixel-retro sellan el grid/paleta
|
||||
comfyui_pixelize_image(raw, "/tmp/knight.png", **ap["post"]["pixelize"])
|
||||
```
|
||||
|
||||
Validado e2e en GPU con el MISMO `knight character` en los 3 estilos (`reports/0190`):
|
||||
gameboy 4 colores verde (`prompt_id 0657e3e3`), ghibli 78 552 colores acuarela
|
||||
(`42f2f492`), pixel-art-retro SDXL 768 16 colores (`84b08581`) — tres looks
|
||||
visiblemente distintos y coherentes. **Gotcha**: en el flujo manual de arriba el `post` no
|
||||
se aplica solo (el caller llama `comfyui_pixelize_image`) — para evitarlo usa el pipeline
|
||||
one-shot `comfyui_generate_styled_asset_oneshot` (abajo), que auto-aplica el post. El LoRA y
|
||||
el checkpoint deben casar de base (SDXL_pixel-art es SDXL → exige juggernaut); OOM en 8 GB →
|
||||
bajar `size`, NO matar procesos.
|
||||
|
||||
## Pipelines one-shot (`gamedev-2d`, impuros)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `comfyui_generate_asset_pack_oneshot_py_pipelines` | `(pack, *, checkpoint="dreamshaper_8…", style="", lora=None, base_seed=0, size=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, …) -> dict` | **Set COHERENTE de assets 2D de un mismo juego de un solo tiro**: `pack=[{"kind","subject"}, …]` → despacha cada `kind` a su builder atómico (26 kinds: item_icon, enemy_creature, prop_object, seamless_tile, ui_hud, particle_texture, …) compartiendo el MISMO `checkpoint`/`lora` + `style` común inyectado al `subject` + `seed = base_seed + i`, encola (`submit`) + espera (`wait`) + descarga (`fetch`) cada uno, y (si `export_godot`) los exporta a Godot. Promoción a pipeline del patrón "N builders con el mismo estilo" (issue 0087). Fail-fast si `kind` desconocido; un OOM aislado no aborta el resto. Probado e2e en GPU SD1.5 512: `magic sword`(item_icon, seed 42) + `goblin warrior`(enemy_creature, seed 43), `style="dark fantasy, hand-painted"` → 2/2 PNG 512×512 RGBA coherentes (`prompt_id f7cfda43` + `11d1d031`, `reports/0179`). Impuro: HTTP + disco + (export) subprocess. |
|
||||
| `comfyui_generate_character_set_oneshot_py_pipelines` | `(character, *, style="game character, full body, clean background", checkpoint="dreamshaper_8…", base_kind="enemy_creature", directions=8, make_directional=True, make_3d=True, directional_model="sv3d", elevation=15.0, seed=0, size=512, directional_size=None, flatten_color=(255,255,255), variant_3d="mini", lora=None, server="127.0.0.1:8188", export_godot=None, out_dir=None, free_vram=True, …) -> dict` | **Set COMPLETO y COHERENTE de UN personaje de un solo tiro** (culminación cross-frontera del grupo): genera del MISMO personaje (1) imagen **base 2D** recortada a alpha, (2) **sprite direccional N-way** (vistas 3D consistentes SV3D/Zero123) y (3) **malla 3D `.glb`** (Hunyuan3D-2). La CLAVE es la coherencia: el direccional y el 3D parten de la **MISMA base 2D aplanada** (`base_flat`), no de tres generaciones independientes → mismo personaje en las tres representaciones, no tres personajes distintos. Compone un builder de personaje (`enemy_creature`/`portrait_avatar`/`topdown_sprite`, elegido por introspección) + `comfyui_flatten_alpha_on_color` (aplana la base recortada sobre blanco — los modelos 3D y `LoadImage` hacen `convert("RGB")` y tiran el alpha) + `comfyui_image_to_3d_oneshot` + `comfyui_build_directional_sprite_workflow` + `submit`/`wait`/`fetch` + `comfyui_export_asset_to_godot`. **Secuencial liberando VRAM** (`POST /free`) entre los pasos pesados, el 3D ANTES del direccional (SV3D es el de mayor pico, ~7.1 GB), para caber en 8 GB. Un fallo aislado (p.ej. OOM en el 3D) NO aborta el resto: deja el set PARCIAL. Promoción a pipeline (issue 0087) de la secuencia que hoy exige 4 llamadas a mano. Probado e2e en GPU — ver `reports/0188`. Impuro: HTTP + disco + (export) subprocess. |
|
||||
| `comfyui_generate_styled_asset_oneshot_py_pipelines` | `(kind, subject, style_preset, *, seed=0, server="127.0.0.1:8188", out_dir=None, export_godot=None, style_override=None, negative_extra=None, free_vram=False, **builder_extra) -> dict` | **Aplica un ESTILO curado a UN asset de un solo tiro, con AUTO-POST**: `comfyui_get_gamedev_style_preset(style_preset)` → `comfyui_apply_style_preset` → despacha `kind` a su builder (REUTILIZA el dispatch `_SUPPORTED` del pack, mismos 26 kinds) → `submit`/`wait`/`fetch` → **auto-aplica el `post` del preset** (`comfyui_pixelize_image` si el estilo lo pide) → export opcional a Godot (como `pixelart` si hubo pixelize → fija el filtro Nearest). Cierra el hueco #1 de los style presets (report 0190): los estilos pixelart (gameboy, pixel-art-retro) salen ya pixelizados del pipeline, **sin llamar a `comfyui_pixelize_image` a mano**. Devuelve `path` (FINAL post-procesado) y `raw_path` (crudo); `path==raw_path` si el estilo no pide post. Kind/estilo desconocido → `ok=False` sin tocar la GPU (validación pura; parte pura aislada en `styled_asset_build_only`). Probado e2e en GPU: mismo `treasure chest`(prop_object) en cyberpunk-neon (`prompt_id 02473baa`), low-poly-flat (`7a186053`) y gameboy (`46b396e2`, crudo 17374 colores → final **4 colores** Game Boy, auto-pixelizado) — ver `reports/0191`. Impuro: HTTP + disco + (export) subprocess. |
|
||||
|
||||
## Ejemplo end-to-end con builder (Fase 1 GPU → Fase 2 CPU → Godot)
|
||||
|
||||
Flujo completo pixel-art: construir workflow → generar en ComfyUI → pixel-perfect → Godot.
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_pixelart_workflow import comfyui_build_pixelart_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 ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
|
||||
# 1. Construir (puro) + 2. generar (GPU)
|
||||
wf = comfyui_build_pixelart_workflow("isometric tiny house, pixel, 32x32 style", use_lcm=True, seed=42)
|
||||
pid = comfyui_submit_workflow(wf)["prompt_id"]
|
||||
outs = comfyui_wait_result(pid, timeout=300)
|
||||
fn = next(img["filename"] for o in outs.values() for img in o.get("images", []))
|
||||
raw = comfyui_fetch_output_image(fn, dest_dir="/tmp")["out_path"]
|
||||
# 3. pixel-perfect (CPU) -> 4. export Godot (ver ejemplo de abajo)
|
||||
px = comfyui_pixelize_image(raw, "/tmp/house_pixel.png", downscale=8, colors=16)
|
||||
```
|
||||
|
||||
VFX: `comfyui_build_vfx_spritesheet_workflow(prompt, num_frames=8)` → submit → fetch N frames
|
||||
→ `comfyui_matting_luma_to_alpha` por frame → montar sheet RGBA con `Image.alpha_composite`
|
||||
(NO `comfyui_build_grid`, que aplana el alpha).
|
||||
|
||||
## Ejemplo canónico de post-proceso
|
||||
|
||||
Flujo: crudo generado en ComfyUI -> pixelizar -> exportar a Godot con Nearest.
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
from ml.comfyui_matting_luma_to_alpha import comfyui_matting_luma_to_alpha
|
||||
from pipelines.comfyui_export_asset_to_godot import comfyui_export_asset_to_godot
|
||||
|
||||
OUT = os.path.expanduser("~/ComfyUI/output")
|
||||
PROJ = os.path.expanduser("~/gamedev/projects/crossy_road")
|
||||
|
||||
# 1. Pixelizar un sprite crudo (SDXL+SDXL_pixel-art) a 16 colores
|
||||
px = comfyui_pixelize_image(f"{OUT}/hero_00001_.png", "/tmp/hero_pixel.png",
|
||||
downscale=8, colors=16)
|
||||
|
||||
# 2. Exportarlo a Godot como pixelart (carpeta sprites/, filtro Nearest, reimport)
|
||||
exp = comfyui_export_asset_to_godot("/tmp/hero_pixel.png", "pixelart", PROJ)
|
||||
print(exp["dest_res_path"], exp["pixelart_filter_set"], exp["reimported"])
|
||||
|
||||
# Rama VFX: frame de humo sobre negro -> RGBA -> carpeta vfx/
|
||||
rgba = comfyui_matting_luma_to_alpha(f"{OUT}/vfx_loop_00007_.png", gamma=1.2, black_point=0.04)
|
||||
comfyui_export_asset_to_godot(rgba["out_path"], "vfx", PROJ)
|
||||
```
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **Montaje de spritesheet dedicado** (grid RGBA + JSON sidecar para Godot/Unity):
|
||||
no hay función propia todavía — el ejemplo VFX monta con `Image.alpha_composite`
|
||||
inline. `comfyui_build_grid` NO sirve (aplana el alpha sobre fondo oscuro). Pendiente
|
||||
de R4 (plan `reports/0140` F2).
|
||||
- **Pipelines one-shot** (build → submit → wait → fetch → post en una call): el
|
||||
**set coherente** ya está promovido — `comfyui_generate_asset_pack_oneshot` genera
|
||||
un pack entero compartiendo checkpoint/style/lora/seed (issue 0087, ver tabla de
|
||||
pipelines arriba). One-shots por-asset individuales (pixelart/sprite/VFX) siguen
|
||||
encadenándose a mano; candidatos a promoción cuando el patrón se repita.
|
||||
- **Sprite turnaround multi-vista** (N direcciones del mismo personaje con identidad fija):
|
||||
cubierto por `comfyui_build_directional_sprite_workflow` (rotación 3D SV3D/Zero123,
|
||||
consistencia rotacional medida — `reports/0187`). Lo que sigue **pendiente** es la
|
||||
orquestación multi-POSE 2D con juez (re-pozar un personaje en N acciones manteniendo
|
||||
identidad, distinto de rotarlo): `comfyui_build_sprite_sheet_workflow` produce UN frame;
|
||||
el pipeline multi-pose con juez sigue pendiente (plan `reports/0137` T2).
|
||||
- **Paletas lospec por red** (`load_lospec_palette`): no incluido. `pixelize` usa
|
||||
paletas fijas embebidas (game-boy/pico-8/nes) o lista de hex, sin HTTP.
|
||||
- **TileSet / SpriteFrames `.tres`**: Godot no los deriva solos; `export_asset_to_godot`
|
||||
copia la textura y avisa, pero no genera el recurso (paso manual o futura función).
|
||||
|
||||
## Prerequisitos / notas
|
||||
|
||||
- **Godot CLI** para el reimport headless: autodetectado en PATH y en
|
||||
`~/godot/Godot_v4.7-stable_linux.x86_64`. Si falta, `export_asset_to_godot` deja el
|
||||
`.import` escrito y lo anota (no falla).
|
||||
- **Filtro Nearest (Godot 4)**: se setea global en `project.godot`
|
||||
(`default_texture_filter=0`), no por `.import`. La función lo asegura para pixelart.
|
||||
- CPU-only: Pillow + numpy del venv del registry. Cero VRAM, cero red.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Capability group: `gamedev-engine` — runtime de juego C++ multiplataforma (PC + WebAssembly)
|
||||
|
||||
Cluster de primitivas C++ que forman el núcleo de un runtime de juego 2D portable a
|
||||
escritorio (Windows/Linux/macOS) y navegador (WebAssembly via emscripten). Stack:
|
||||
**SDL3** (ventana + input + GL context) + **sokol_gfx** (render) + **miniaudio**
|
||||
(audio). Nacido del Issue 0072b.
|
||||
|
||||
A diferencia del grupo hermano `gamedev-2d` (generación de *assets* 2D con ComfyUI y
|
||||
puente a Godot), este grupo es el **motor que ejecuta el juego**: el bucle de
|
||||
simulación, la cámara, el input, el render por lotes y el audio. No genera arte; lo
|
||||
consume en tiempo de ejecución.
|
||||
|
||||
Tag: `gamedev-engine`. Filtro: `mcp__registry__fn_search query="" tag="gamedev-engine"`.
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace | Pureza |
|
||||
|---|---|---|---|
|
||||
| `game_loop_cpp_gamedev` | `loop_run(SDL_Window*, const LoopCfg&) -> void` | Game loop fixed-timestep estilo Glenn Fiedler ("Fix Your Timestep"): desacopla `on_fixed_update` (dt fijo) de `on_render` (factor de interpolación), acumulador con cap anti spiral-of-death. Branch automático desktop (while loop) vs `__EMSCRIPTEN__` (`emscripten_set_main_loop`). | impure |
|
||||
| `input_unified_cpp_gamedev` | `input_begin_frame(InputState&); input_process_event(InputState&, const SDL_Event*)` | Snapshot unificado de input por frame para SDL3: mapea teclado (WASD+flechas), ratón, gamepad y touch a botones lógicos (left/right/up/down/action_a..y/start/back) y ejes analógicos, con flags `*_pressed` de rising edge limpio. | impure |
|
||||
| `camera_2d_cpp_gamedev` | `world_to_screen / screen_to_world(Camera2D, Vec2) -> Vec2; visible_world_rect(Camera2D) -> Rect; view_proj_matrix(Camera2D, float[16])` | Cámara ortográfica 2D pura (pos centro, zoom, rotación, viewport): conversiones world↔screen, AABB visible y matriz view-projection 4×4 column-major lista para cualquier renderer. Fast-path sin trig si `rotation==0`. | pure |
|
||||
| `sokol_setup_cpp_gfx` | `make_environment() -> sg_environment; make_swapchain(int w, int h) -> sg_swapchain` | Builders puros para inicializar sokol_gfx sobre un GL context creado por SDL3 (no por sokol_app): `sg_environment` con defaults RGBA8 + depth/stencil y `sg_swapchain` del default framebuffer del contexto activo. | pure |
|
||||
| `sprite_batch_cpp_gfx` | `sprite_batch_create(int cap=4096) -> SpriteBatch; sprite_batch_begin/draw/end` | Batched textured quad renderer sobre sokol_gfx: begin/draw/end con auto-flush por cambio de atlas o capacidad llena. Vertex layout pos+uv+color, alpha blending estándar, GLSL 330 / GLES 300. Base de plataformeros, top-down y UI sprites. | impure |
|
||||
| `audio_engine_cpp_gamedev` | `engine_init() -> Engine; engine_shutdown(Engine&); engine_set_volume(Engine&, float)` | Lifecycle del engine de audio basado en miniaudio (single-header, public domain): inicializa device default, master volume, libera recursos. Cross-platform (WASAPI/ALSA/CoreAudio y WebAudio bajo emscripten). Única TU que define `MINIAUDIO_IMPLEMENTATION`. | impure |
|
||||
| `audio_play_cpp_gamedev` | `sound_load(Engine&, const char*) -> Sound; sound_play/stop/set_volume/destroy(Sound&); play_sound_oneshot(Engine&, const char*, float)` | Reproducción de audio sobre `fn::audio::Engine`: carga con streaming desde disco (wav/mp3/flac/ogg), play/stop/volumen por sonido, y helper fire-and-forget para one-shots sin handle. | impure |
|
||||
| `build_wasm_cpp_app_bash_infra` | `build_wasm_cpp_app(app_name, [--no-budget-check]) -> void` | Compila una app C++ del registry (`cpp/apps/<name>`) a WebAssembly via emscripten. Produce `build/wasm/<name>/<name>.{html,js,wasm,wasm.gz}`. Falla si el gzip supera 2 MB (budget). | impure |
|
||||
|
||||
## Ejemplo canónico (esqueleto de un juego 2D)
|
||||
|
||||
Las primitivas se componen así dentro del `main()` de una app C++ del registry. Cada
|
||||
identificador (`fn::game_loop`, `fn::input`, `fn::camera2d`, `fn::gfx`, `fn::audio`)
|
||||
proviene de la función homónima del registry; la app solo aporta la lógica de juego.
|
||||
|
||||
```cpp
|
||||
// 1. Ventana + GL context con SDL3, sokol_gfx encima (sokol_setup)
|
||||
sg_setup(...); // usa make_environment() del registry
|
||||
auto swap = fn::gfx::make_swapchain(W, H);
|
||||
auto batch = fn::gfx::sprite_batch_create(4096);
|
||||
auto audio = fn::audio::engine_init();
|
||||
fn::audio::play_sound_oneshot(audio, "assets/music.ogg", 0.6f);
|
||||
|
||||
// 2. Estado de cámara e input
|
||||
fn::game::Camera2D cam{ .pos = {0,0}, .zoom = 1.0f, .viewport = {W, H} };
|
||||
fn::input::InputState in{};
|
||||
|
||||
// 3. Game loop fixed-timestep: simulación e interpolación desacopladas
|
||||
fn::game::LoopCfg cfg{
|
||||
.on_event = [&](const SDL_Event* e){ fn::input::input_process_event(in, e); },
|
||||
.on_fixed_update = [&](float dt){ /* mover entidades usando in.left_pressed... */ },
|
||||
.on_render = [&](float alpha){
|
||||
fn::gfx::sprite_batch_begin(batch);
|
||||
// draw sprites con cam.view_proj_matrix(...) como transform
|
||||
fn::gfx::sprite_batch_end(batch);
|
||||
},
|
||||
};
|
||||
fn::game::loop_run(window, cfg);
|
||||
|
||||
// 4. Distribuir a navegador: build_wasm_cpp_app "<app>" -> build/wasm/<app>/
|
||||
```
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **Generación de assets** (sprites, tiles, VFX): es el grupo hermano `gamedev-2d`
|
||||
(ComfyUI → post-proceso → Godot). Este grupo solo los *renderiza* en runtime.
|
||||
- **Física / colisiones / ECS**: no incluidos. El `on_fixed_update` recibe el dt fijo;
|
||||
la simulación la pone la app.
|
||||
- **TileSet / mapas / escenas**: no hay sistema de niveles; `sprite_batch` dibuja quads
|
||||
sueltos.
|
||||
- **App end-to-end consumidora**: a fecha de hoy estas primitivas son el núcleo del
|
||||
runtime (Issue 0072b) pero **no hay todavía una app C++ que las componga end-to-end**
|
||||
(varias están marcadas `pendiente-usar`). El ejemplo de arriba es el esqueleto
|
||||
previsto, no un binario validado. Cuando exista la primera app, su `app.md`
|
||||
declarará estas IDs en `uses_functions` y servirá de validación e2e.
|
||||
|
||||
## Prerequisitos / notas
|
||||
|
||||
- **SDL3** para ventana + input + GL context; **sokol_gfx** (vendored en `cpp/vendor/`)
|
||||
para render; **miniaudio** (single-header) para audio.
|
||||
- Target nativo: GLSL 330. Target WebAssembly (emscripten): GLES 300 — `sprite_batch`
|
||||
emite ambos shaders.
|
||||
- `build_wasm_cpp_app` exige el SDK de emscripten en el entorno y respeta un budget de
|
||||
2 MB gzip por defecto (`--no-budget-check` para saltarlo).
|
||||
@@ -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,311 @@
|
||||
# Integración ComfyUI → Godot: puente de assets ordenado y gestionado
|
||||
|
||||
Diseño del puente entre la generación de assets en **ComfyUI** (`~/ComfyUI/`) y su consumo en
|
||||
proyectos **Godot 4** (`~/gamedev/projects/`). El objetivo es que un asset generado (sprite,
|
||||
tileset, pixelart, spritesheet VFX, audio, malla 3D GLB) viaje a un proyecto Godot a la carpeta
|
||||
correcta, con los *import settings* adecuados a su tipo, sin romper los archivos `.import`
|
||||
existentes ni desordenar el proyecto.
|
||||
|
||||
- **Fecha:** 26/06/2026
|
||||
- **Alcance:** mapa de ambas estructuras + convención de carpetas destino + tabla
|
||||
tipo-de-asset → carpeta Godot → import settings + propuesta de función(es) del registry para
|
||||
automatizar el traslado. Es documento de diseño; la implementación se delega a `fn-constructor`.
|
||||
- **Fuera de alcance:** generación (GPU, otro agente), implementación de las funciones, descarga
|
||||
de modelos.
|
||||
|
||||
Documento hermano del catálogo de capacidades de generación: `~/ComfyUI/CAPABILITIES.md` (fuera
|
||||
del repo) y `docs/capabilities/comfyui-overview.md` (versionable). Este documento añade la pata
|
||||
que faltaba: **qué pasa con el asset una vez generado**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mapa de ComfyUI (origen de los assets)
|
||||
|
||||
ComfyUI vive en `~/ComfyUI/` (clon del repo, no versionado en `fn_registry`). Las carpetas
|
||||
relevantes para el puente son:
|
||||
|
||||
```
|
||||
~/ComfyUI/
|
||||
├── output/ # ★ AQUÍ caen TODOS los assets generados
|
||||
│ ├── *.png # imágenes (sprites, pixelart, tiles) — ~150 hoy
|
||||
│ ├── *.webp # spritesheets animados / vídeo corto (SaveAnimatedWEBP)
|
||||
│ ├── *.mp4 # vídeo (SaveVideo)
|
||||
│ ├── *.glb / *.obj / *.ply # mallas 3D (SaveGLB)
|
||||
│ └── 3D/ # subcarpeta de salidas 3D (texturizadas, multi-vista)
|
||||
├── input/ # imágenes de entrada (img2img, image-to-3d)
|
||||
├── models/ + /mnt/2tb/comfyui_models/ # checkpoints/loras/vae/... (vía extra_model_paths.yaml)
|
||||
├── user/default/workflows/ # grafos UI, agrupados por capacidad (01_txt2img, 02_img2img, …)
|
||||
├── skills_library/<slug>/ # recetas de estilo reproducibles (recipe.json + samples)
|
||||
└── custom_nodes/ # nodos extra (PixelArt-Detector, IPAdapter, Hunyuan3D, …)
|
||||
```
|
||||
|
||||
- **`output/` es el único punto de origen del puente.** Cada nodo `Save*` escribe ahí con un
|
||||
patrón de nombre `<prefijo>_NNNNN_.<ext>` (sufijo numérico de 5 dígitos). Ejemplos reales en
|
||||
disco hoy: `bench160_3000_00001_.png`, `svd_motion_hi_00001_.webp`, `3d_robot_mesh_00001_.glb`,
|
||||
`output/3D/character_clean_textured_00001_.glb`.
|
||||
- **Modelos centralizados** en `/mnt/2tb/comfyui_models/` vía `extra_model_paths.yaml`
|
||||
(`is_default: true`). No intervienen en el puente (son insumo de generación, no asset de salida).
|
||||
- **Tipos que generamos hoy** (recuento real del `output/`): `png` (mayoría), `glb` (15), `mp4`
|
||||
(6), `webp` (2), `obj`/`ply` (formatos 3D crudos). **Audio aún no se genera en ComfyUI** (no hay
|
||||
`wav`/`ogg`/`mp3` en `output/`); el plan de audio existe como report aparte. El puente lo
|
||||
contempla igualmente porque Godot lo consume y porque el audio puede llegar de otra fuente.
|
||||
- Catálogo navegable de qué sabemos generar y con qué función/grafo: `~/ComfyUI/CAPABILITIES.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Mapa de Godot (destino de los assets)
|
||||
|
||||
Los proyectos viven en `~/gamedev/projects/` (fuera de `fn_registry`, igual que ComfyUI). Hay una
|
||||
**biblioteca maestra** de assets en `~/gamedev/assets/` y, por proyecto, una copia local de los
|
||||
assets que usa. Proyectos reales localizados:
|
||||
|
||||
- `~/gamedev/projects/crossy_road/` (juego "LizardRoad", Godot 4.7, móvil portrait 640×1280)
|
||||
- `~/gamedev/projects/risk/` (pilotaje previo)
|
||||
|
||||
Estructura canónica de un proyecto Godot 4 (tomada de `crossy_road`, que es el patrón real):
|
||||
|
||||
```
|
||||
~/gamedev/projects/crossy_road/
|
||||
├── project.godot # config del proyecto (nombre, autoloads, rendering, display)
|
||||
├── .godot/ # ★ caché de import (regenerable) — NUNCA se versiona ni se toca a mano
|
||||
│ └── imported/ # binarios .ctex/.sample/... generados desde los .import
|
||||
├── assets/ # assets del proyecto (copia local de la biblioteca)
|
||||
│ ├── biomas/ agua.png + agua.png.import (par obligatorio por cada asset)
|
||||
│ ├── kenney/ packs CC0
|
||||
│ ├── external/ otros CC0
|
||||
│ └── audio/sfx/ step.wav + step.wav.import
|
||||
├── scenes/ *.tscn (escenas)
|
||||
├── scripts/ *.gd + *.gd.uid
|
||||
├── addons/godot_ai/ addon del MCP (control del editor desde Claude)
|
||||
└── export_presets.cfg / android/ (build móvil)
|
||||
```
|
||||
|
||||
### Cómo importa Godot 4 cada asset — el archivo `.import`
|
||||
|
||||
**Regla de oro:** en Godot, **cada asset es un par `<archivo>` + `<archivo>.import`**. El
|
||||
`.import` es un INI que declara el `importer`, el `type` de recurso resultante, un `uid://`
|
||||
estable y los parámetros de importación. Godot genera el binario importado en `.godot/imported/`
|
||||
y lo regenera al reimportar. **Romper o desincronizar el `.import` = el asset no carga o se
|
||||
reimporta con settings por defecto.** Por eso el puente debe respetar este par.
|
||||
|
||||
Ejemplos reales de `.import` por tipo (de `crossy_road`):
|
||||
|
||||
**Textura** (`importer="texture"`, `type="CompressedTexture2D"`):
|
||||
```ini
|
||||
[remap]
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cedkstexk3ciw"
|
||||
path="res://.godot/imported/agua.png-<hash>.ctex"
|
||||
[params]
|
||||
compress/mode=0 ; 0=Lossless (correcto para pixelart), 2=VRAM
|
||||
mipmaps/generate=false ; off para 2D/pixelart
|
||||
process/fix_alpha_border=true
|
||||
detect_3d/compress_to=1
|
||||
```
|
||||
|
||||
**Audio WAV** (`importer="wav"`, `type="AudioStreamWAV"`):
|
||||
```ini
|
||||
[remap]
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
[params]
|
||||
edit/loop_mode=0 ; 0=Disabled (sfx), 1=Forward (música en bucle)
|
||||
compress/mode=2
|
||||
force/mono=false
|
||||
```
|
||||
|
||||
**Malla 3D GLB** (`importer="scene"`, produce `PackedScene`): el GLB se importa como escena glTF;
|
||||
sus opciones (escala raíz, generación de colisión, manejo de materiales/texturas) viven en el
|
||||
`.import` de tipo `scene`. No había ninguno en los proyectos actuales (son 2D), pero es el
|
||||
importer canónico de Godot para `.glb`/`.gltf`.
|
||||
|
||||
### El gotcha del filtro de textura (Godot 4 ≠ Godot 3)
|
||||
|
||||
En **Godot 4** el filtro Nearest/Linear **no es un campo del `.import` por defecto** (en Godot 3
|
||||
sí lo era). El filtro se controla de dos formas:
|
||||
|
||||
1. **Global del proyecto (recomendado, KISS):** Project Settings → Rendering → Textures →
|
||||
Canvas Textures → Default Texture Filter → **Nearest**. En `project.godot` es la clave
|
||||
`rendering/textures/canvas_textures/default_texture_filter=0` (0 = Nearest). Tras cambiarlo hay
|
||||
que **reimportar** las texturas.
|
||||
2. **Override por asset:** en el panel Import de una textura concreta, Texture > Filter →
|
||||
`Nearest` (override del default). Esto sí escribe la opción en su `.import`.
|
||||
|
||||
> **Hallazgo (read-only) en `crossy_road`:** su `project.godot` **no** declara
|
||||
> `default_texture_filter`, así que usa el default **Linear** → cualquier asset pixelart se ve
|
||||
> **borroso** (sus `biomas/*.png` tienen `mipmaps/generate=false` y `compress/mode=0`, correcto,
|
||||
> pero les falta el Nearest). Para un proyecto pixelart, setear el global una vez es el primer
|
||||
> paso del puente. (No se modificó nada; queda anotado para el constructor.)
|
||||
|
||||
---
|
||||
|
||||
## 3. El puente: convención de carpetas destino en Godot
|
||||
|
||||
Convención propuesta para la copia local de assets dentro de cada proyecto, alineada con lo que
|
||||
ya existe (`assets/audio/sfx/`, `assets/kenney/`) y extendida a todos los tipos que generamos:
|
||||
|
||||
```
|
||||
res://assets/
|
||||
├── sprites/ # PNG individuales: personajes, props, objetos sueltos
|
||||
├── tilesets/ # PNG de tiles + el recurso TileSet .tres derivado
|
||||
├── vfx/ # spritesheets / animaciones (WEBP, o PNG en grid) → SpriteFrames/AtlasTexture
|
||||
├── audio/
|
||||
│ ├── sfx/ # efectos cortos (WAV) — loop OFF
|
||||
│ └── music/ # música (OGG/WAV) — loop ON
|
||||
├── models/ # mallas 3D GLB (+ sus texturas/materiales)
|
||||
└── _generated/ # opcional: zona de aterrizaje de lo recién traído de ComfyUI, antes de clasificar
|
||||
```
|
||||
|
||||
- **`pixelart` no es una carpeta**, es un *atributo transversal*: un sprite, tile o VFX puede ser
|
||||
pixelart. Lo que cambia es el import setting (Nearest + Lossless), no la ubicación. El pixelart
|
||||
va a `sprites/` / `tilesets/` / `vfx/` según su rol, y el proyecto entero se marca Nearest a
|
||||
nivel global cuando es un juego pixelart.
|
||||
- **Biblioteca maestra vs copia local:** `~/gamedev/assets/` es la fuente ordenada; cada proyecto
|
||||
guarda dentro su copia (`<proyecto>/assets/...`) porque Godot referencia por rutas `res://`
|
||||
relativas a la raíz del proyecto. El puente copia de `~/ComfyUI/output/` → bien a la biblioteca
|
||||
maestra, bien directo a un proyecto.
|
||||
|
||||
### Naming y versionado
|
||||
|
||||
- ComfyUI nombra `<prefijo>_NNNNN_.<ext>` (p. ej. `svd_motion_hi_00001_.webp`). Al exportar,
|
||||
**renombrar a snake_case limpio y semántico** quitando el sufijo `_NNNNN_` y los guiones bajos
|
||||
de cola: `svd_motion_hi_00001_.webp` → `explosion_loop.webp`.
|
||||
- Sin espacios ni mayúsculas en nombres de archivo (consistencia con `res://` y multiplataforma).
|
||||
- Versionado: sufijo opcional `_vN` cuando se itera un asset que ya está en uso
|
||||
(`hero_idle.png` → `hero_idle_v2.png`), para no pisar el `uid://` del que ya referencian escenas.
|
||||
**Nunca** sobrescribir un asset en uso sin querer: cambia su contenido pero conserva su `uid`,
|
||||
lo que puede ser deseable (hot-swap) o no (regresión visual). Decisión explícita por asset.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tabla: tipo de asset ComfyUI → carpeta Godot → import settings clave
|
||||
|
||||
| Tipo (ComfyUI) | Ext salida | Carpeta Godot destino | Importer Godot (`type`) | Import settings clave |
|
||||
|---|---|---|---|---|
|
||||
| **Sprite individual** | `.png` | `res://assets/sprites/` | `texture` → `CompressedTexture2D` | `compress/mode=0` (Lossless); `mipmaps/generate=false`; repeat off; filtro = default del proyecto (Linear si arte vectorial, Nearest si pixelart) |
|
||||
| **Pixelart** (sprite/tile) | `.png` | `sprites/` o `tilesets/` (según rol) | `texture` → `CompressedTexture2D` | **CRÍTICO: Nearest** (global `default_texture_filter=0` o override por asset); `compress/mode=0` Lossless; `mipmaps/generate=false`; repeat off |
|
||||
| **Tileset** | `.png` | `res://assets/tilesets/` | `texture` + recurso **`TileSet` (.tres)** manual/script | Nearest (suele ser pixelart); además crear `TileSet` con tamaño de celda (region grid) — Godot **no** deriva el TileSet automáticamente del PNG |
|
||||
| **VFX spritesheet** | `.webp` / `.png` (grid) | `res://assets/vfx/` | `texture` → **`SpriteFrames`** (AnimatedSprite2D) o **`AtlasTexture`** por frame | Nearest si pixelart; definir `hframes`/`vframes` o regiones por frame; Godot **no** convierte un sheet a `SpriteFrames` solo: se hace en editor o por script de import |
|
||||
| **Audio SFX** | `.wav` | `res://assets/audio/sfx/` | `wav` → `AudioStreamWAV` | `edit/loop_mode=0` (Disabled); `compress/mode` según peso |
|
||||
| **Audio música** | `.ogg` / `.wav` | `res://assets/audio/music/` | `oggvorbisstr` (OGG) / `wav` | **loop ON** (`edit/loop_mode=1` en WAV; `loop=true` en OGG) |
|
||||
| **Malla 3D** | `.glb` (preferido) | `res://assets/models/` | `scene` → `PackedScene` (glTF) | escala raíz coherente; generar colisión si se necesita; si la textura es pixelart, poner el material en Nearest; preferir `.glb` sobre `.obj`/`.ply` (los lleva embebidos) |
|
||||
|
||||
Notas que la tabla condensa:
|
||||
|
||||
- **Godot no automatiza dos conversiones clave:** (1) PNG de tiles → recurso `TileSet`, y (2)
|
||||
spritesheet → `SpriteFrames`. Ambas requieren un paso manual en editor o un script de import
|
||||
(o un addon, p. ej. el TexturePacker importer / aseprite-importers). El puente puede generar el
|
||||
`.tres` por script a partir de la geometría conocida del grid.
|
||||
- **WEBP animado (SVD):** nuestros `svd_*.webp` son clips de Stable Video Diffusion. Para usarlos
|
||||
como animación 2D en Godot, lo robusto es **descomponer en frames** (PNG en grid) y construir
|
||||
`SpriteFrames`, no cargar el WEBP animado tal cual.
|
||||
- **3D pixelart/low-poly:** el GLB importa como escena; cuidar que el material no aplique filtro
|
||||
Linear a una textura pixelart (se setea en el material/import de la malla).
|
||||
|
||||
---
|
||||
|
||||
## 5. Propuesta de función(es) del registry (diseño, NO implementación)
|
||||
|
||||
Búsqueda en el registry: `mcp__registry__fn_search query="godot export asset import"` → **0
|
||||
resultados**. Gap limpio. Hoy llevar un asset de ComfyUI a Godot es manual (copiar + abrir editor
|
||||
+ tocar import a mano). Alineado con la doctrina del registry (registry-first + crecer por
|
||||
composición de helpers atómicos, issue 0087), la propuesta es **un pipeline one-shot que compone
|
||||
helpers pequeños**:
|
||||
|
||||
### Pipeline principal
|
||||
|
||||
```
|
||||
export_asset_to_godot_py_pipelines (impura, kind=pipeline, tag de grupo: godot, comfyui)
|
||||
|
||||
Firma:
|
||||
export_asset_to_godot(
|
||||
asset_path: str, # ruta en ~/ComfyUI/output/ (o cualquier archivo)
|
||||
kind: str, # "sprite" | "pixelart" | "tileset" | "vfx" | "sfx" | "music" | "model"
|
||||
godot_project: str, # ruta raíz del proyecto Godot destino
|
||||
dest_name: str = "", # nombre limpio destino (default: snake_case del origen sin _NNNNN_)
|
||||
pixelart: bool = False, # fuerza Nearest + Lossless en la textura
|
||||
loop: bool = False, # audio en bucle (música)
|
||||
reimport: bool = True, # lanza reimport headless al final
|
||||
) -> dict # {dest_res_path, import_written, reimported, warnings[]}
|
||||
```
|
||||
|
||||
Comportamiento:
|
||||
1. Resolver la carpeta destino por `kind` (tabla §4) dentro de `res://assets/`.
|
||||
2. Copiar el archivo con nombre limpio (snake_case, sin sufijo `_NNNNN_`).
|
||||
3. Escribir/asegurar el `.import` adecuado al tipo (texture/wav/scene) con los settings clave.
|
||||
4. Si `pixelart=True`, además asegurar el global del proyecto
|
||||
(`default_texture_filter=0` en `project.godot`) o el override por asset.
|
||||
5. Si `reimport=True`, lanzar reimport headless para que Godot regenere `.godot/imported/`.
|
||||
6. Devolver el `res://` final + avisos (p. ej. "tileset copiado pero falta crear el TileSet .tres",
|
||||
"WEBP animado: descomponer en frames antes de SpriteFrames").
|
||||
|
||||
### Helpers atómicos que compone (delegar a `fn-constructor` en paralelo)
|
||||
|
||||
| Helper (id tentativo) | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `godot_asset_dest_dir_py_core` | pura | mapea `kind` → subdir de `res://assets/` (tabla §4) |
|
||||
| `godot_clean_asset_name_py_core` | pura | quita sufijo `_NNNNN_`, normaliza a snake_case, sin espacios |
|
||||
| `godot_write_texture_import_py_infra` | impura | escribe `.import` de textura (compress/mipmaps/filter) preservando `uid` si ya existe |
|
||||
| `godot_write_audio_import_py_infra` | impura | escribe `.import` de audio (loop_mode) |
|
||||
| `godot_ensure_pixelart_project_py_infra` | impura | setea `default_texture_filter=0` en `project.godot` (idempotente) |
|
||||
| `godot_reimport_headless_bash_infra` | impura | `godot --headless --path <proj> --import` para regenerar la caché |
|
||||
| `godot_build_spriteframes_tres_py_infra` | impura | genera `.tres` de `SpriteFrames`/`AtlasTexture` a partir de un sheet + geometría de grid |
|
||||
| `godot_build_tileset_tres_py_infra` | impura | genera `.tres` de `TileSet` a partir de un PNG + tamaño de celda |
|
||||
|
||||
### DoD esbozado (según `dod_quality.md`)
|
||||
|
||||
- **Golden:** `export_asset_to_godot("~/ComfyUI/output/hero.png", "pixelart",
|
||||
"~/gamedev/projects/crossy_road")` deja `res://assets/sprites/hero.png` + su `.import` con
|
||||
Nearest + Lossless, el `project.godot` con `default_texture_filter=0`, y la reimport headless
|
||||
sale con exit 0; assert: el `.import` contiene el override Nearest y el binario aparece en
|
||||
`.godot/imported/`.
|
||||
- **Edge 1 (audio música):** `kind="music", loop=True` → `.import` con `edit/loop_mode=1`.
|
||||
- **Edge 2 (GLB 3D):** `kind="model"` copia a `res://assets/models/` y la escena glTF carga
|
||||
(reimport sin error).
|
||||
- **Edge 3 (tileset):** copia el PNG y **avisa** que falta el `.tres` del TileSet (o lo genera con
|
||||
`godot_build_tileset_tres`), sin romper nada.
|
||||
- **Error path:** `kind` desconocido → error claro sin copiar nada; `godot_project` sin
|
||||
`project.godot` → aborta y no escribe; nunca sobrescribe un asset en uso sin `dest_name`
|
||||
explícito.
|
||||
- **Idempotencia:** re-exportar el mismo asset preserva el `uid://` existente (no rompe las
|
||||
referencias de las escenas que ya lo usan).
|
||||
|
||||
### ¿Automatizar el import vía MCP godot-ai?
|
||||
|
||||
El addon `godot_ai` (MCP, server `127.0.0.1:8000/mcp`) está presente en `crossy_road` y `risk`.
|
||||
Con el editor abierto, `filesystem_manage` op `reimport` (recibe `paths`) puede forzar reimport
|
||||
desde el editor vivo. **Pero** la convención (`CONVENTIONS.md` de gamedev) ya observa que el
|
||||
reimport por MCP suele ser **innecesario**: `project_run` recompila desde disco al arrancar, y un
|
||||
`godot --headless --import` regenera la caché sin editor abierto. **Recomendación:** el puente usa
|
||||
**reimport headless por CLI** (`godot_reimport_headless_bash_infra`) como mecanismo por defecto
|
||||
(no requiere editor abierto ni MCP), y deja el MCP como opción cuando el editor ya está vivo y se
|
||||
quiere refrescar en caliente.
|
||||
|
||||
---
|
||||
|
||||
## 6. Resumen operativo (TL;DR)
|
||||
|
||||
1. **Origen:** todo asset generado cae en `~/ComfyUI/output/` (PNG/WEBP/MP4/GLB).
|
||||
2. **Destino:** `res://assets/{sprites,tilesets,vfx,audio/{sfx,music},models}/` dentro del
|
||||
proyecto Godot; cada asset es un par `archivo` + `archivo.import`.
|
||||
3. **Import por tipo:** textura (Lossless, mipmaps off, **Nearest si pixelart**), audio (loop
|
||||
off=sfx / on=música), GLB (escena glTF). Tabla §4.
|
||||
4. **Gotcha Godot 4:** el Nearest pixelart se setea **global** (`default_texture_filter=0`) o por
|
||||
override de asset — **no** es un flag por defecto del `.import`. `crossy_road` hoy está en
|
||||
Linear (pixelart borroso): anotado.
|
||||
5. **Godot no automatiza** PNG→TileSet ni sheet→SpriteFrames: paso manual o script de import.
|
||||
6. **Automatización:** pipeline `export_asset_to_godot` (gap confirmado, 0 funciones hoy) que
|
||||
compone helpers atómicos + reimport headless. Diseño en §5; implementación se delega a
|
||||
`fn-constructor`.
|
||||
|
||||
## Fuentes
|
||||
|
||||
- ComfyUI: `~/ComfyUI/CAPABILITIES.md`, `~/ComfyUI/extra_model_paths.yaml`, listado de
|
||||
`~/ComfyUI/output/` (read-only).
|
||||
- Godot: `~/gamedev/CONVENTIONS.md`, `~/gamedev/README.md`,
|
||||
`~/gamedev/projects/crossy_road/project.godot` y sus `*.import` (read-only).
|
||||
- Godot 4 pixel art texture filter: [GDQuest — Setting up pixel art graphics in Godot 4](https://www.gdquest.com/library/pixel_art_setup_godot4/),
|
||||
[Godot Forum — How to import pixel art in Godot 4](https://forum.godotengine.org/t/how-to-import-pixel-art-in-godot-4/7105).
|
||||
- Godot 4 sprite sheets: [Godot docs — 2D sprite animation](https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html),
|
||||
[godot-4-aseprite-importers](https://github.com/nklbdev/godot-4-aseprite-importers).
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_set_file_input
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def cdp_set_file_input(selector: str, file_paths, *, port: int = 9222, target_url_substr: str = '', timeout_s: float = 10.0) -> dict"
|
||||
description: "Asigna uno o varios archivos a un <input type=file> de una pestana de un Chrome con remote debugging, via CDP, SIN abrir el dialogo nativo del sistema operativo. Localiza el target por substring de URL, abre el WebSocket y ejecuta DOM.enable -> DOM.getDocument -> DOM.querySelector(selector) -> DOM.setFileInputFiles con las rutas ABSOLUTAS. Es el unico metodo robusto para subir archivos por CDP: el navegador no permite escribir el value de un file input desde JS (seguridad) y simular drag&drop es fragil; setFileInputFiles inyecta los File y dispara el evento change que la SPA escucha. Base de whatsapp_send_image y de cualquier flujo de subida de archivos sobre el navegador diario sin robar el foco al usuario."
|
||||
tags: [cdp, browser, automation, upload, file-input, python, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "os", "urllib.request", "websocket"]
|
||||
params_schema:
|
||||
params:
|
||||
- name: selector
|
||||
desc: "Selector CSS del <input type=file> destino. Debe resolver a UN elemento (se usa el primer match). El input puede estar oculto (display:none); CDP lo localiza igual."
|
||||
- name: file_paths
|
||||
desc: "Ruta (str) o lista de rutas a asignar. Se expanden (~) y se convierten a rutas ABSOLUTAS; cada una debe existir en disco o se aborta con ok=False antes de tocar la red."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging de Chrome. Default 9222."
|
||||
- name: target_url_substr
|
||||
desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'."
|
||||
- name: timeout_s
|
||||
desc: "Timeout en segundos para la conexion WebSocket. Default 10.0."
|
||||
output: "dict {ok: bool, error: str, node_id: int (nodeId CDP del input localizado, 0 si no se encontro), selector: str (eco), files: list[str] (rutas absolutas asignadas)}. ok=True solo si el input se localizo y setFileInputFiles no devolvio error. Nunca lanza: errores de archivo/red/conexion/transport se devuelven en 'error' con ok=False."
|
||||
tested: true
|
||||
tests: ["test_golden_asigna_archivos_al_input", "test_edge_archivo_inexistente_ok_false_sin_red", "test_edge_selector_no_encontrado_ok_false", "test_error_create_connection_lanza_ok_false"]
|
||||
test_file_path: "python/functions/browser/cdp_set_file_input_test.py"
|
||||
file_path: "python/functions/browser/cdp_set_file_input.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.cdp_set_file_input import cdp_set_file_input
|
||||
|
||||
# Requiere un Chrome lanzado con --remote-debugging-port=9222.
|
||||
# Adjuntar una imagen al input de subida de una pestana (WhatsApp Web, un formulario, etc).
|
||||
# El input "vivo" suele exponerse tras pulsar el boton "Adjuntar"/"Subir" (haz el click antes).
|
||||
res = cdp_set_file_input(
|
||||
'input[type="file"][multiple]',
|
||||
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
|
||||
target_url_substr="whatsapp",
|
||||
)
|
||||
print(res["ok"], res["node_id"], res["error"])
|
||||
# -> True 120
|
||||
```
|
||||
|
||||
O directo por CLI: `python3 python/functions/browser/cdp_set_file_input.py 'input[type="file"]' /ruta/abs.png whatsapp`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites **subir/adjuntar un archivo** a una pestana abierta sin que aparezca el
|
||||
dialogo nativo de archivos del sistema operativo (que CDP no puede operar). Es la primitiva
|
||||
de subida sobre la que se construye `whatsapp_send_image_py_browser` y cualquier
|
||||
automatizacion de formularios con `<input type=file>`. El patron tipico: primero un click
|
||||
real (`cdp_click_xy_py_browser`) en el boton que expone el input vivo, luego esta funcion
|
||||
con el selector del input y la ruta absoluta del archivo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El input debe existir en el DOM al llamar.** Muchas SPAs (WhatsApp Web) solo crean/activan
|
||||
el `<input type=file>` "vivo" DESPUES de pulsar el boton de adjuntar. Haz el click real
|
||||
primero; si asignas sobre un input persistente/decoy, `setFileInputFiles` puede devolver ok
|
||||
pero la SPA no reacciona (no aparece el preview).
|
||||
- **Rutas ABSOLUTAS obligatorias.** `setFileInputFiles` exige rutas absolutas; la funcion ya
|
||||
convierte (`os.path.abspath` + expanduser), pero el archivo debe existir o aborta con ok=False
|
||||
antes de abrir la conexion.
|
||||
- **El selector debe resolver al input correcto.** Si hay varios `<input type=file>` (uno por
|
||||
tipo: fotos, documento...), afina el selector (`[multiple]`, `[accept*="image"]`). Si no
|
||||
matchea ninguno, devuelve `ok=False` con "no element matches selector".
|
||||
- **Dispara `change`, no `input`.** `DOM.setFileInputFiles` emite el evento `change` nativo
|
||||
(que la mayoria de uploads escuchan). Si una SPA solo escucha otro evento, no bastara.
|
||||
- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin
|
||||
remote debugging, `GET /json` falla y devuelve `ok=False`.
|
||||
- Nunca lanza: errores de archivo/red/WS/transport se reportan en `error` con `ok=False`.
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Asigna archivos a un <input type=file> de una pestana de Chrome via Chrome DevTools Protocol.
|
||||
|
||||
Primitiva de input CDP para subir archivos SIN abrir el dialogo nativo del sistema
|
||||
operativo: localiza un target (pestana) por substring de su URL, abre el WebSocket de
|
||||
depuracion y ejecuta la secuencia `DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector`
|
||||
(localiza el input por selector CSS) -> `DOM.setFileInputFiles` (asigna las rutas
|
||||
absolutas al input).
|
||||
|
||||
Es el unico metodo robusto para adjuntar archivos por CDP: el navegador no permite
|
||||
escribir el `value` de un `<input type=file>` desde JavaScript (seguridad), y simular
|
||||
drag&drop es fragil. `DOM.setFileInputFiles` inyecta los `File` directamente y dispara el
|
||||
evento `change` que la SPA escucha. Base de `whatsapp_send_image` y de cualquier flujo de
|
||||
subida de archivos sobre el navegador diario sin robar el foco al usuario.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import websocket
|
||||
|
||||
|
||||
def _call(ws, msg_id: int, method: str, params: dict) -> dict:
|
||||
"""Envia un comando CDP y drena eventos hasta la respuesta con el mismo id."""
|
||||
ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
|
||||
while True:
|
||||
raw = ws.recv()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception: # noqa: BLE001 — frame no-JSON, ignorar
|
||||
continue
|
||||
if parsed.get("id") == msg_id:
|
||||
return parsed
|
||||
|
||||
|
||||
def cdp_set_file_input(
|
||||
selector: str,
|
||||
file_paths,
|
||||
*,
|
||||
port: int = 9222,
|
||||
target_url_substr: str = "",
|
||||
timeout_s: float = 10.0,
|
||||
) -> dict:
|
||||
"""Asigna uno o varios archivos a un `<input type=file>` de una pestana de Chrome.
|
||||
|
||||
Localiza el target `page` por substring de URL, abre el WebSocket CDP y ejecuta
|
||||
`DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector(selector)` ->
|
||||
`DOM.setFileInputFiles`. Equivale a que el usuario elija esos archivos en el dialogo
|
||||
nativo, pero sin abrirlo: ideal para automatizar subidas (adjuntar imagen en WhatsApp,
|
||||
subir un fichero a un formulario) sobre una pestana ya abierta.
|
||||
|
||||
Args:
|
||||
selector: Selector CSS del `<input type=file>` destino. Debe resolver a UN
|
||||
elemento (se usa el primer match). El input puede estar oculto.
|
||||
file_paths: Ruta (str) o lista de rutas a asignar. Se expanden (`~`) y se
|
||||
convierten a rutas ABSOLUTAS; cada una debe existir en disco.
|
||||
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||
target_url_substr: Substring que debe contener la URL del target (pestana). Si
|
||||
"", usa el primer target de tipo "page".
|
||||
timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0.
|
||||
|
||||
Returns:
|
||||
dict con claves:
|
||||
ok: bool — True si el input se localizo y los archivos se asignaron sin error.
|
||||
error: str — mensaje de error (vacio si ok).
|
||||
node_id: int — nodeId CDP del input localizado (0 si no se encontro).
|
||||
selector: str — eco del selector usado.
|
||||
files: list[str] — rutas absolutas que se intentaron asignar.
|
||||
|
||||
Nunca lanza: errores de archivo, red, conexion o transport se devuelven en
|
||||
"error" con ok=False.
|
||||
"""
|
||||
# 1. Normalizar y validar las rutas (antes de tocar la red).
|
||||
if isinstance(file_paths, str):
|
||||
raw_paths = [file_paths]
|
||||
else:
|
||||
raw_paths = list(file_paths)
|
||||
abs_paths = [os.path.abspath(os.path.expanduser(p)) for p in raw_paths]
|
||||
|
||||
if not abs_paths:
|
||||
return {"ok": False, "error": "no file paths provided",
|
||||
"node_id": 0, "selector": selector, "files": []}
|
||||
missing = [p for p in abs_paths if not os.path.isfile(p)]
|
||||
if missing:
|
||||
return {"ok": False, "error": f"file(s) not found: {missing}",
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
# 2. Listar targets via HTTP y elegir el primer page que matchee.
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/json", timeout=5
|
||||
) as resp:
|
||||
targets = json.loads(resp.read().decode())
|
||||
except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar
|
||||
return {"ok": False, "error": str(e),
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
chosen = None
|
||||
for t in targets:
|
||||
if t.get("type") != "page":
|
||||
continue
|
||||
url = t.get("url", "")
|
||||
if target_url_substr == "" or target_url_substr in url:
|
||||
chosen = t
|
||||
break
|
||||
|
||||
if chosen is None:
|
||||
return {"ok": False, "error": f"no target matching {target_url_substr}",
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
ws_url = chosen.get("webSocketDebuggerUrl", "")
|
||||
|
||||
# 3. Abrir WS y correr la secuencia DOM.
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=timeout_s)
|
||||
except Exception as e: # noqa: BLE001 — conexion WS
|
||||
return {"ok": False, "error": str(e),
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
try:
|
||||
_call(ws, 1, "DOM.enable", {})
|
||||
doc = _call(ws, 2, "DOM.getDocument", {"depth": 0})
|
||||
root_id = doc.get("result", {}).get("root", {}).get("nodeId", 0)
|
||||
if not root_id:
|
||||
return {"ok": False, "error": "DOM.getDocument did not return a root nodeId",
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
qs = _call(ws, 3, "DOM.querySelector",
|
||||
{"nodeId": root_id, "selector": selector})
|
||||
node_id = qs.get("result", {}).get("nodeId", 0)
|
||||
if not node_id:
|
||||
return {"ok": False, "error": f"no element matches selector: {selector}",
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
|
||||
sf = _call(ws, 4, "DOM.setFileInputFiles",
|
||||
{"files": abs_paths, "nodeId": node_id})
|
||||
err = sf.get("error")
|
||||
if err:
|
||||
return {"ok": False, "error": json.dumps(err),
|
||||
"node_id": node_id, "selector": selector, "files": abs_paths}
|
||||
except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv
|
||||
return {"ok": False, "error": str(e),
|
||||
"node_id": 0, "selector": selector, "files": abs_paths}
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001 — cierre best-effort
|
||||
pass
|
||||
|
||||
return {"ok": True, "error": "", "node_id": node_id,
|
||||
"selector": selector, "files": abs_paths}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sel = sys.argv[1] if len(sys.argv) > 1 else 'input[type="file"]'
|
||||
path = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
substr = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
out = cdp_set_file_input(sel, path, port=9222, target_url_substr=substr)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests para cdp_set_file_input — mockean urlopen + create_connection.
|
||||
|
||||
Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y
|
||||
websocket.create_connection (un fake que responde a cada comando DOM por su id, con un
|
||||
nodeId de raiz, un nodeId de input y una respuesta vacia para setFileInputFiles). Asi NO
|
||||
hace falta Chrome. Para la validacion de existencia de archivo se usan rutas reales
|
||||
(__file__) y rutas inexistentes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from browser import cdp_set_file_input as mod # noqa: E402
|
||||
from browser.cdp_set_file_input import cdp_set_file_input # noqa: E402
|
||||
|
||||
_THIS = os.path.abspath(__file__)
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self._payload).encode()
|
||||
|
||||
|
||||
class _FakeWS:
|
||||
"""WebSocket falso que responde a cada comando DOM por method."""
|
||||
|
||||
def __init__(self, *, query_node_id=4242, set_error=None):
|
||||
self.sent = []
|
||||
self._inbox = []
|
||||
self.closed = False
|
||||
self.query_node_id = query_node_id
|
||||
self.set_error = set_error
|
||||
|
||||
def send(self, raw):
|
||||
msg = json.loads(raw)
|
||||
self.sent.append(msg)
|
||||
mid = msg["id"]
|
||||
method = msg["method"]
|
||||
if method == "DOM.getDocument":
|
||||
resp = {"id": mid, "result": {"root": {"nodeId": 1}}}
|
||||
elif method == "DOM.querySelector":
|
||||
resp = {"id": mid, "result": {"nodeId": self.query_node_id}}
|
||||
elif method == "DOM.setFileInputFiles":
|
||||
if self.set_error is not None:
|
||||
resp = {"id": mid, "error": self.set_error}
|
||||
else:
|
||||
resp = {"id": mid, "result": {}}
|
||||
else: # DOM.enable y demas
|
||||
resp = {"id": mid, "result": {}}
|
||||
self._inbox.append(json.dumps(resp))
|
||||
|
||||
def recv(self):
|
||||
if self._inbox:
|
||||
return self._inbox.pop(0)
|
||||
return ""
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch(targets, ws_obj=None, create_conn_exc=None):
|
||||
orig_urlopen = mod.urllib.request.urlopen
|
||||
orig_create = mod.websocket.create_connection
|
||||
|
||||
def fake_urlopen(url, timeout=5):
|
||||
return _FakeResp(targets)
|
||||
|
||||
def fake_create(ws_url, timeout=10):
|
||||
if create_conn_exc is not None:
|
||||
raise create_conn_exc
|
||||
return ws_obj
|
||||
|
||||
mod.urllib.request.urlopen = fake_urlopen
|
||||
mod.websocket.create_connection = fake_create
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
mod.urllib.request.urlopen = orig_urlopen
|
||||
mod.websocket.create_connection = orig_create
|
||||
|
||||
|
||||
_TARGETS = [
|
||||
{"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"},
|
||||
]
|
||||
|
||||
|
||||
def test_golden_asigna_archivos_al_input():
|
||||
"""setFileInputFiles recibe la ruta ABSOLUTA y el nodeId del input localizado."""
|
||||
ws = _FakeWS(query_node_id=777)
|
||||
with _patch(_TARGETS, ws_obj=ws):
|
||||
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||
|
||||
assert res["ok"] is True
|
||||
assert res["error"] == ""
|
||||
assert res["node_id"] == 777
|
||||
assert res["files"] == [_THIS]
|
||||
# El ultimo comando es setFileInputFiles con la ruta absoluta y el nodeId del input.
|
||||
setcmd = [m for m in ws.sent if m["method"] == "DOM.setFileInputFiles"][0]
|
||||
assert setcmd["params"]["files"] == [_THIS]
|
||||
assert setcmd["params"]["nodeId"] == 777
|
||||
assert ws.closed is True
|
||||
|
||||
|
||||
def test_edge_archivo_inexistente_ok_false_sin_red():
|
||||
"""Una ruta inexistente devuelve ok=False antes de abrir cualquier conexion."""
|
||||
res = cdp_set_file_input('input[type="file"]', "/no/existe/imagen.png",
|
||||
target_url_substr="whatsapp")
|
||||
assert res["ok"] is False
|
||||
assert "not found" in res["error"]
|
||||
assert res["node_id"] == 0
|
||||
|
||||
|
||||
def test_edge_selector_no_encontrado_ok_false():
|
||||
"""Si querySelector devuelve nodeId 0, no se asignan archivos y ok=False."""
|
||||
ws = _FakeWS(query_node_id=0)
|
||||
with _patch(_TARGETS, ws_obj=ws):
|
||||
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||
assert res["ok"] is False
|
||||
assert "no element matches selector" in res["error"]
|
||||
# NO se llamo a setFileInputFiles.
|
||||
assert all(m["method"] != "DOM.setFileInputFiles" for m in ws.sent)
|
||||
|
||||
|
||||
def test_error_create_connection_lanza_ok_false():
|
||||
"""Si create_connection lanza, se captura y devuelve ok=False sin relanzar."""
|
||||
with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")):
|
||||
res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp")
|
||||
assert res["ok"] is False
|
||||
assert "ws down" in res["error"]
|
||||
assert res["files"] == [_THIS]
|
||||
@@ -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="IMG_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="IMG_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,105 @@
|
||||
---
|
||||
name: whatsapp_send_image
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def whatsapp_send_image(name: str, image_path: str, *, caption: str = '', port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict"
|
||||
description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el <input type=file> vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera la bandeja inline y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Si hay caption, lo envia como mensaje de texto de seguimiento via whatsapp_send_message (en la WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de forma fiable, asi que viaja como segunda burbuja [imagen][caption]). Accion con efecto: envia la imagen DE VERDAD, no reversible."
|
||||
tags: [whatsapp, cdp, browser, automation, image, upload, python, navegator]
|
||||
uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_set_file_input_py_browser, whatsapp_send_message_py_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "sys", "time", "json"]
|
||||
params_schema:
|
||||
params:
|
||||
- name: name
|
||||
desc: "Nombre EXACTO del chat o grupo destinatario tal y como aparece en la lista lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta al destinatario correcto antes de adjuntar."
|
||||
- name: image_path
|
||||
desc: "Ruta de la imagen a enviar. Se expande (~) y se convierte a ruta ABSOLUTA; debe existir en disco o aborta con error sin abrir el chat."
|
||||
- name: caption
|
||||
desc: "Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento (segunda burbuja [imagen][caption]) via whatsapp_send_message; '' (default) envia solo la imagen. La WhatsApp Web compacta actual no permite automatizar el caption embebido en la imagen de forma fiable."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging de Chrome. Default 9222."
|
||||
- name: target_url_substr
|
||||
desc: "Substring que debe contener la URL del target (pestana). Default 'whatsapp'."
|
||||
- name: open_first
|
||||
desc: "Si True (default), abre el chat por su nombre antes de adjuntar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra name (aborta si no coincide)."
|
||||
output: "dict {ok: bool (imagen + caption enviados), sent: bool (imagen enviada), caption_sent: bool (caption de seguimiento enviado, False si no habia o fallo), recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si todo ok)}. sent=True solo si la imagen se adjunto y se envio dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'."
|
||||
tested: true
|
||||
tests: ["test_golden_envia_imagen_y_caption_de_seguimiento", "test_envia_sin_caption_no_manda_texto", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"]
|
||||
test_file_path: "python/functions/browser/whatsapp_send_image_test.py"
|
||||
file_path: "python/functions/browser/whatsapp_send_image.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.whatsapp_send_image import whatsapp_send_image
|
||||
|
||||
# Requiere WhatsApp Web abierto y logueado (y DESBLOQUEADO si tiene app-lock) en un
|
||||
# Chrome lanzado con --remote-debugging-port=9222.
|
||||
res = whatsapp_send_image(
|
||||
"NOTAS WASAP",
|
||||
"/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png",
|
||||
caption="item icon: potion",
|
||||
)
|
||||
print(res)
|
||||
# -> {"ok": True, "sent": True, "recipient": "NOTAS WASAP",
|
||||
# "image": ".../item_icon_potion_00001_.png", "caption": "item icon: potion", "error": ""}
|
||||
```
|
||||
|
||||
O directo por CLI: `python3 python/functions/browser/whatsapp_send_image.py "NOTAS WASAP" /ruta/abs.png "mi caption"`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites **enviar una imagen (foto, captura, asset generado) a un contacto o grupo
|
||||
por su nombre exacto** en WhatsApp Web, sin abrir ventana nueva ni robar el foco al usuario.
|
||||
Es la version "imagen" de `whatsapp_send_message_py_browser`: usala cuando ya tienes el
|
||||
nombre exacto del destinatario y la ruta de un archivo de imagen en disco. Para texto plano,
|
||||
usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Accion con efecto: envia la imagen DE VERDAD.** No es reversible. Verifica que `name` es
|
||||
EXACTO antes de llamar (la salvaguarda abre el chat y comprueba el composer, pero el nombre
|
||||
debe coincidir con el `title` de la lista lateral).
|
||||
- **App-lock de WhatsApp Web.** Si la cuenta tiene el "bloqueo de la aplicacion" activo, el DOM
|
||||
de chats no se renderiza (solo la pantalla de password) y la funcion fallara al abrir el chat.
|
||||
Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton
|
||||
"Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale
|
||||
vacia aunque la sesion siga logueada.
|
||||
- **El menu "Adjuntar" es un TOGGLE (`aria-expanded`).** El `<input type=file>` solo queda "vivo"
|
||||
mientras el menu esta ABIERTO; asignar al input con el menu cerrado es un decoy (no abre preview).
|
||||
Clickar "Adjuntar" cuando YA esta abierto lo CIERRA. Por eso la funcion clicka solo si
|
||||
`aria-expanded != "true"` y reintenta hasta verlo abierto (no un click ciego). La WhatsApp Web
|
||||
actual usa una **bandeja de medios INLINE compacta** sobre el composer (no un drawer a pantalla
|
||||
completa).
|
||||
- **El envio se verifica por la ultima fila de `#main`, NO por contar filas.** Las filas de `#main`
|
||||
se VIRTUALIZAN (las antiguas se desmontan al llegar nuevas), asi que el total se mantiene casi
|
||||
constante. La funcion confirma el envio comprobando que la bandeja se vacio (adjuntos=0) Y que la
|
||||
ultima fila renderizada es ya una imagen (`img[src^="blob:"]`).
|
||||
- **El caption NO se embebe en la imagen: viaja como mensaje de texto de seguimiento.** En esta
|
||||
WhatsApp Web compacta hay dos botones de envio cuando hay media: "Enviar N seleccionados" (envia
|
||||
la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte,
|
||||
descartando la media en cola). No hay un campo de caption por-imagen automatizable de forma
|
||||
fiable. Por eso la funcion envia primero la imagen (boton de la bandeja) y, si hay `caption`, lo
|
||||
manda despues como mensaje de texto via `whatsapp_send_message` (`open_first=False`): el resultado
|
||||
es [imagen][caption] como dos burbujas. `caption_sent` indica si esa segunda burbuja salio.
|
||||
- **Selector de aria-label en espanol.** El preview se detecta por `[aria-label="Quitar archivo
|
||||
adjunto"]` y el boton de adjuntar por `[aria-label="Adjuntar"]`: dependen del idioma de la UI
|
||||
(espanol). En otro locale habria que ajustar los aria-labels.
|
||||
- **Las imagenes se ACUMULAN en la bandeja.** Cada `setFileInputFiles` anade una miniatura; si un
|
||||
envio queda a medias, la siguiente llamada podria sumar a las pendientes. La funcion verifica que
|
||||
la bandeja queda vacia tras enviar (adjuntos=0) para confirmar; si no se cierra, devuelve
|
||||
`sent=False` con "envio incierto".
|
||||
- **Salvaguarda anti-destinatario-equivocado**: con `open_first=True` abre y verifica el chat; con
|
||||
`open_first=False` lee el aria-label del composer y aborta si no contiene `name`.
|
||||
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin traerla a primer plano.
|
||||
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar
|
||||
con cautela y bajo tu responsabilidad.
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Envia una imagen (con caption opcional) a un chat de WhatsApp Web via Chrome DevTools Protocol.
|
||||
|
||||
Compone `whatsapp_open_chat` (abrir + verificar destinatario) con primitivas CDP del
|
||||
registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`) y `whatsapp_send_message` para
|
||||
adjuntar y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al
|
||||
sistema.
|
||||
|
||||
Flujo (modelo de bandeja de medios INLINE de la WhatsApp Web actual), con salvaguarda
|
||||
anti-envio-al-contacto-equivocado:
|
||||
|
||||
1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con
|
||||
`open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer
|
||||
contiene el nombre; si no, aborta por seguridad.
|
||||
2. ASEGURA que el menu "Adjuntar" esta ABIERTO (es un toggle `aria-expanded`: clickar
|
||||
cuando ya esta abierto lo cierra). Solo entonces el `<input type=file>` queda "vivo".
|
||||
3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la
|
||||
imagen aparece como miniatura en la bandeja inline.
|
||||
4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click
|
||||
real en el boton de enviar la bandeja (icono `wds-ic-send-filled`).
|
||||
5. Verifica el envio comprobando que la bandeja se cerro Y que la ultima fila de `#main`
|
||||
es ahora una imagen (las filas se virtualizan, asi que NO sirve contar filas).
|
||||
6. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via
|
||||
`whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la
|
||||
WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de
|
||||
forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption].
|
||||
|
||||
Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen
|
||||
(y el caption) de verdad.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from browser.cdp_eval import cdp_eval
|
||||
from browser.cdp_click_xy import cdp_click_xy
|
||||
from browser.cdp_set_file_input import cdp_set_file_input
|
||||
from browser.whatsapp_open_chat import whatsapp_open_chat
|
||||
from browser.whatsapp_send_message import whatsapp_send_message
|
||||
|
||||
|
||||
def _center(expr: str, port: int, substr: str):
|
||||
"""Evalua una expresion que devuelve JSON {x,y} (o null) y la parsea a dict/None."""
|
||||
r = cdp_eval(expr, port=port, target_url_substr=substr)
|
||||
val = r.get("value")
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception: # noqa: BLE001 — value no-JSON
|
||||
return None
|
||||
|
||||
|
||||
def _adj_expanded(port: int, substr: str) -> str:
|
||||
"""Estado aria-expanded del boton 'Adjuntar' ('true'/'false'/'no-btn')."""
|
||||
r = cdp_eval(
|
||||
'/*ADJEXP*/var e=document.querySelector(\'button[aria-label="Adjuntar"]\'); '
|
||||
"e?e.getAttribute('aria-expanded'):'no-btn'",
|
||||
port=port, target_url_substr=substr,
|
||||
)
|
||||
return r.get("value") or "no-btn"
|
||||
|
||||
|
||||
def _attachment_count(port: int, substr: str) -> int:
|
||||
"""Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto')."""
|
||||
r = cdp_eval(
|
||||
'/*PREVIEW*/document.querySelectorAll(\'[aria-label="Quitar archivo adjunto"]\').length',
|
||||
port=port, target_url_substr=substr,
|
||||
)
|
||||
v = r.get("value")
|
||||
return v if isinstance(v, int) else 0
|
||||
|
||||
|
||||
def _last_row_is_image(port: int, substr: str) -> bool:
|
||||
"""True si la ultima fila renderizada de #main contiene una imagen (blob)."""
|
||||
r = cdp_eval(
|
||||
'/*LASTIMG*/(() => {const r=[...document.querySelectorAll(\'#main [role="row"]\')]'
|
||||
".slice(-1)[0]; return r?!!r.querySelector('img[src^=\"blob:\"]'):false;})()",
|
||||
port=port, target_url_substr=substr,
|
||||
)
|
||||
return bool(r.get("value"))
|
||||
|
||||
|
||||
def whatsapp_send_image(
|
||||
name: str,
|
||||
image_path: str,
|
||||
*,
|
||||
caption: str = "",
|
||||
port: int = 9222,
|
||||
target_url_substr: str = "whatsapp",
|
||||
open_first: bool = True,
|
||||
) -> dict:
|
||||
"""Envia una imagen (con caption opcional de seguimiento) a un chat de WhatsApp Web.
|
||||
|
||||
Accion CON EFECTO: envia la imagen DE VERDAD (no reversible). Verifica `name`.
|
||||
|
||||
Args:
|
||||
name: Nombre EXACTO del chat/grupo destinatario, tal y como aparece en la lista
|
||||
lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta
|
||||
al destinatario correcto antes de adjuntar.
|
||||
image_path: Ruta de la imagen a enviar. Se expande (`~`) y se convierte a ruta
|
||||
ABSOLUTA; debe existir en disco.
|
||||
caption: Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento
|
||||
(segunda burbuja [imagen][caption]) via `whatsapp_send_message`; "" (default)
|
||||
envia solo la imagen.
|
||||
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||
target_url_substr: Substring que debe contener la URL del target (pestana). Default
|
||||
"whatsapp".
|
||||
open_first: Si True (default), abre el chat por su nombre antes de adjuntar. Si
|
||||
False, asume el chat ya abierto pero verifica el aria-label del composer contra
|
||||
`name` (aborta si no coincide).
|
||||
|
||||
Returns:
|
||||
dict con claves:
|
||||
ok: bool — True si la imagen se envio y (si habia caption) el caption tambien.
|
||||
sent: bool — True si la IMAGEN se envio.
|
||||
caption_sent: bool — True si el caption de seguimiento se envio (False si no
|
||||
habia caption o si fallo).
|
||||
recipient: str — el nombre solicitado.
|
||||
image: str — ruta absoluta de la imagen.
|
||||
caption: str — caption solicitado.
|
||||
error: str — motivo del fallo (vacio si todo ok).
|
||||
|
||||
Nunca lanza: los fallos se reportan en "sent"/"ok" + "error".
|
||||
"""
|
||||
S = target_url_substr
|
||||
abs_img = os.path.abspath(os.path.expanduser(image_path))
|
||||
|
||||
def fail(error: str) -> dict:
|
||||
return {"ok": False, "sent": False, "caption_sent": False, "recipient": name,
|
||||
"image": abs_img, "caption": caption, "error": error}
|
||||
|
||||
# 0. La imagen debe existir.
|
||||
if not os.path.isfile(abs_img):
|
||||
return fail(f"imagen no encontrada: {abs_img}")
|
||||
|
||||
# 1. Abrir + verificar destinatario correcto (salvaguarda anti-equivocacion).
|
||||
if open_first:
|
||||
o = whatsapp_open_chat(name, port=port, target_url_substr=S)
|
||||
if not o.get("opened"):
|
||||
return fail(o.get("reason", "no se pudo abrir el chat"))
|
||||
else:
|
||||
chk = cdp_eval(
|
||||
'/*LABEL*/var b=document.querySelector(\'footer div[contenteditable="true"]\'); '
|
||||
"b?b.getAttribute('aria-label'):null",
|
||||
port=port, target_url_substr=S,
|
||||
)
|
||||
if name not in (chk.get("value") or ""):
|
||||
return fail("el chat abierto no coincide con el destinatario; abortado por seguridad")
|
||||
|
||||
# 2. Asegurar el menu "Adjuntar" ABIERTO. Es un TOGGLE (aria-expanded): clickar cuando
|
||||
# ya esta abierto lo cierra y el input vivo desaparece. Por eso clickamos SOLO si no
|
||||
# esta expandido y reintentamos hasta verlo abierto.
|
||||
adj = _center(
|
||||
'/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');'
|
||||
"if(!e)return null;const b=e.getBoundingClientRect();"
|
||||
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
|
||||
port, S,
|
||||
)
|
||||
if not adj:
|
||||
return fail("boton 'Adjuntar' no encontrado en el footer")
|
||||
for _ in range(3):
|
||||
if _adj_expanded(port, S) == "true":
|
||||
break
|
||||
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S)
|
||||
time.sleep(0.6)
|
||||
if _adj_expanded(port, S) != "true":
|
||||
return fail("no se pudo abrir el menu 'Adjuntar' (aria-expanded sigue en false)")
|
||||
|
||||
# 3. Asignar la imagen al primer <input type=file> (el "vivo" mientras el menu esta
|
||||
# abierto). El composer queda VACIO, asi que luego el unico wds-ic-send-filled es el
|
||||
# de enviar la bandeja.
|
||||
r = cdp_set_file_input('input[type="file"]', abs_img,
|
||||
port=port, target_url_substr=S)
|
||||
if not r.get("ok"):
|
||||
return fail("no se pudo adjuntar la imagen: " + r.get("error", ""))
|
||||
|
||||
# 4. Esperar a que la bandeja aparezca (adjunto presente).
|
||||
attached = False
|
||||
for _ in range(15):
|
||||
time.sleep(0.2)
|
||||
if _attachment_count(port, S) > 0:
|
||||
attached = True
|
||||
break
|
||||
if not attached:
|
||||
return fail("el preview no aparecio tras adjuntar la imagen")
|
||||
time.sleep(0.3)
|
||||
|
||||
# 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled).
|
||||
snd = _center(
|
||||
'/*SEND*/(() => {const e=document.querySelector(\'span[data-icon="wds-ic-send-filled"]\');'
|
||||
"if(!e)return null;const b=e.getBoundingClientRect();"
|
||||
"if(b.width===0)return null;"
|
||||
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()",
|
||||
port, S,
|
||||
)
|
||||
if not snd:
|
||||
return fail("boton de enviar (wds-ic-send-filled) no encontrado")
|
||||
cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S)
|
||||
|
||||
# 6. Confirmar envio: la bandeja se cierra (adjuntos=0) Y la ultima fila de #main es ya
|
||||
# una imagen. Las filas de #main se VIRTUALIZAN, asi que contar filas no sirve.
|
||||
image_sent = False
|
||||
for _ in range(20):
|
||||
time.sleep(0.2)
|
||||
if _attachment_count(port, S) == 0 and _last_row_is_image(port, S):
|
||||
image_sent = True
|
||||
break
|
||||
if not image_sent:
|
||||
return fail("no se confirmo la imagen en el chat tras pulsar enviar; envio incierto")
|
||||
|
||||
# 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja).
|
||||
caption_sent = False
|
||||
if caption:
|
||||
m = whatsapp_send_message(name, caption, port=port, target_url_substr=S,
|
||||
open_first=False)
|
||||
caption_sent = bool(m.get("sent"))
|
||||
if not caption_sent:
|
||||
return {"ok": False, "sent": True, "caption_sent": False, "recipient": name,
|
||||
"image": abs_img, "caption": caption,
|
||||
"error": "imagen enviada pero el caption fallo: " + m.get("reason", "")}
|
||||
|
||||
return {"ok": True, "sent": True, "caption_sent": caption_sent, "recipient": name,
|
||||
"image": abs_img, "caption": caption, "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
|
||||
img = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
cap = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
out = whatsapp_send_image(chat, img, caption=cap,
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests para whatsapp_send_image.
|
||||
|
||||
whatsapp_send_image compone whatsapp_open_chat con tres primitivas CDP (cdp_eval,
|
||||
cdp_click_xy, cdp_set_file_input) y `whatsapp_send_message` (para el caption de
|
||||
seguimiento), y requiere un Chrome vivo. Aqui se mockean todas con monkeypatch sobre el
|
||||
modulo `browser.whatsapp_send_image` (donde quedan ligados los nombres por el
|
||||
`from browser.X import Y`), de modo que NO hace falta Chrome.
|
||||
|
||||
El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en
|
||||
ella (`/*ADJUNTAR*/`, `/*ADJEXP*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LASTIMG*/`, `/*LABEL*/`).
|
||||
El estado simula el ciclo real: el menu Adjuntar arranca cerrado y se abre tras un click;
|
||||
tras pulsar enviar (/*SEND*/) la bandeja se vacia (/*PREVIEW*/ -> 0) y la ultima fila pasa a
|
||||
ser una imagen (/*LASTIMG*/ -> True).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import browser.whatsapp_send_image as wsi # noqa: E402
|
||||
from browser.whatsapp_send_image import whatsapp_send_image # noqa: E402
|
||||
|
||||
# Imagen real para pasar la validacion de existencia (este propio archivo de test).
|
||||
_IMG = os.path.abspath(__file__)
|
||||
|
||||
|
||||
class _Spy:
|
||||
def __init__(self, ret=None):
|
||||
self.calls = []
|
||||
self.ret = ret if ret is not None else {"ok": True}
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
return self.ret
|
||||
|
||||
|
||||
def _make_eval(label="Escribir un mensaje para el grupo NOTAS WASAP", state=None):
|
||||
state = state if state is not None else {}
|
||||
|
||||
def fake(expr, *, port=9222, target_url_substr=""):
|
||||
if "/*ADJUNTAR*/" in expr:
|
||||
return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""}
|
||||
if "/*ADJEXP*/" in expr:
|
||||
n = state.get("adjexp_n", 0)
|
||||
state["adjexp_n"] = n + 1
|
||||
return {"ok": True, "value": ("false" if n == 0 else "true"), "error": ""}
|
||||
if "/*SEND*/" in expr:
|
||||
state["after_send"] = True
|
||||
return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""}
|
||||
if "/*PREVIEW*/" in expr:
|
||||
return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""}
|
||||
if "/*LASTIMG*/" in expr:
|
||||
return {"ok": True, "value": bool(state.get("after_send")), "error": ""}
|
||||
if "/*LABEL*/" in expr:
|
||||
return {"ok": True, "value": label, "error": ""}
|
||||
return {"ok": True, "value": None, "error": ""}
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None,
|
||||
send_msg_ret={"sent": True}):
|
||||
open_spy = _Spy(ret=open_ret if open_ret is not None else {"opened": True, "name": "x"})
|
||||
click_spy = _Spy(ret={"ok": True})
|
||||
set_spy = _Spy(ret=set_ret)
|
||||
sendmsg_spy = _Spy(ret=send_msg_ret)
|
||||
monkeypatch.setattr(wsi, "whatsapp_open_chat", open_spy)
|
||||
monkeypatch.setattr(wsi, "cdp_eval", eval_fn)
|
||||
monkeypatch.setattr(wsi, "cdp_click_xy", click_spy)
|
||||
monkeypatch.setattr(wsi, "cdp_set_file_input", set_spy)
|
||||
monkeypatch.setattr(wsi, "whatsapp_send_message", sendmsg_spy)
|
||||
monkeypatch.setattr(wsi.time, "sleep", lambda *a, **k: None)
|
||||
return open_spy, click_spy, set_spy, sendmsg_spy
|
||||
|
||||
|
||||
def test_golden_envia_imagen_y_caption_de_seguimiento(monkeypatch):
|
||||
cap = "item icon: potion"
|
||||
state = {}
|
||||
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch, eval_fn=_make_eval(state=state))
|
||||
|
||||
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption=cap,
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
|
||||
assert res["sent"] is True and res["ok"] is True
|
||||
assert res["caption_sent"] is True
|
||||
assert res["recipient"] == "NOTAS WASAP"
|
||||
assert res["image"] == _IMG
|
||||
assert res["caption"] == cap
|
||||
assert res["error"] == ""
|
||||
# Se adjunto la imagen (ruta absoluta) por setFileInputFiles, una sola vez.
|
||||
assert len(set_spy.calls) == 1
|
||||
assert set_spy.calls[0][0][0] == 'input[type="file"]'
|
||||
assert set_spy.calls[0][0][1] == _IMG
|
||||
# Dos clicks reales: abrir Adjuntar (menu cerrado al inicio) y Enviar.
|
||||
assert len(click_spy.calls) == 2
|
||||
# El caption viaja como mensaje de texto de seguimiento, open_first=False.
|
||||
assert len(sendmsg_spy.calls) == 1
|
||||
assert sendmsg_spy.calls[0][0][0] == "NOTAS WASAP"
|
||||
assert sendmsg_spy.calls[0][0][1] == cap
|
||||
assert sendmsg_spy.calls[0][1].get("open_first") is False
|
||||
|
||||
|
||||
def test_envia_sin_caption_no_manda_texto(monkeypatch):
|
||||
state = {}
|
||||
_, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch, eval_fn=_make_eval(state=state))
|
||||
|
||||
res = whatsapp_send_image("NOTAS WASAP", _IMG, caption="",
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
|
||||
assert res["sent"] is True
|
||||
assert res["caption_sent"] is False
|
||||
# Sin caption: NO se manda mensaje de texto de seguimiento.
|
||||
assert len(sendmsg_spy.calls) == 0
|
||||
assert len(set_spy.calls) == 1
|
||||
|
||||
|
||||
def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch):
|
||||
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch, eval_fn=_make_eval())
|
||||
|
||||
res = whatsapp_send_image("NOTAS WASAP", "/no/existe/foo.png",
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
|
||||
assert res["sent"] is False and res["ok"] is False
|
||||
assert "no encontrada" in res["error"]
|
||||
# Ni siquiera se intento abrir el chat ni adjuntar.
|
||||
assert len(open_spy.calls) == 0
|
||||
assert len(set_spy.calls) == 0
|
||||
|
||||
|
||||
def test_edge_open_fallido_error_sin_adjuntar(monkeypatch):
|
||||
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch, eval_fn=_make_eval(),
|
||||
open_ret={"opened": False, "name": "x",
|
||||
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"})
|
||||
|
||||
res = whatsapp_send_image("Inexistente", _IMG,
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
|
||||
assert res["sent"] is False
|
||||
assert "no encontrado" in res["error"]
|
||||
# No se adjunto ni se hizo click cuando el chat no abrio.
|
||||
assert len(set_spy.calls) == 0
|
||||
assert len(click_spy.calls) == 0
|
||||
|
||||
|
||||
def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch):
|
||||
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch,
|
||||
eval_fn=_make_eval(label="Escribir un mensaje para el grupo OTRO CHAT"))
|
||||
|
||||
res = whatsapp_send_image("NOTAS WASAP", _IMG,
|
||||
port=9222, target_url_substr="whatsapp",
|
||||
open_first=False)
|
||||
|
||||
assert res["sent"] is False
|
||||
assert "abortado por seguridad" in res["error"]
|
||||
# SEGURIDAD: no se adjunto ni se clicko nada.
|
||||
assert len(set_spy.calls) == 0
|
||||
assert len(click_spy.calls) == 0
|
||||
|
||||
|
||||
def test_error_set_file_input_falla_no_envia(monkeypatch):
|
||||
state = {}
|
||||
open_spy, click_spy, set_spy, sendmsg_spy = _patch_common(
|
||||
monkeypatch, eval_fn=_make_eval(state=state),
|
||||
set_ret={"ok": False, "error": "no element matches selector"})
|
||||
|
||||
res = whatsapp_send_image("NOTAS WASAP", _IMG,
|
||||
port=9222, target_url_substr="whatsapp")
|
||||
|
||||
assert res["sent"] is False
|
||||
assert "no se pudo adjuntar" in res["error"]
|
||||
# Se abrio Adjuntar (1 click) e intento adjuntar (1 set), pero NO se envio nada.
|
||||
assert len(set_spy.calls) == 1
|
||||
assert len(click_spy.calls) == 1
|
||||
assert len(sendmsg_spy.calls) == 0
|
||||
@@ -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:]))
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: godot_clean_asset_name
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str"
|
||||
description: "Normaliza el nombre de archivo de un asset salido de ComfyUI (patron <prefijo>_NNNNN_.<ext>) a un nombre limpio y seguro para res://: snake_case, minusculas, sin el sufijo numerico _NNNNN_, sin espacios ni caracteres raros, conservando la extension. Pura: solo manipula el string, no toca disco. Pensada para el puente ComfyUI -> Godot."
|
||||
tags: [godot, gamedev-2d, core, assets, naming]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: filename
|
||||
desc: "nombre o ruta del asset de origen (se toma solo el basename)."
|
||||
- name: override
|
||||
desc: "nombre base deseado sin extension; si se pasa se usa en lugar del nombre de origen (igual se normaliza a snake_case y se le anade la extension del origen). keyword-only."
|
||||
output: "nombre de archivo limpio 'snake_case.ext' (ext en minuscula; sin punto si el origen no tenia extension)."
|
||||
tested: true
|
||||
tests: [test_strips_comfyui_suffix, test_normalizes_spaces_and_case, test_takes_basename_from_path, test_override_name, test_empty_fallback]
|
||||
test_file_path: "python/functions/core/godot_clean_asset_name_test.py"
|
||||
file_path: "python/functions/core/godot_clean_asset_name.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from core.godot_clean_asset_name import godot_clean_asset_name
|
||||
|
||||
godot_clean_asset_name("svd_motion_hi_00001_.webp") # 'svd_motion_hi.webp'
|
||||
godot_clean_asset_name("/x/ComfyUI/output/bench_00042_.png") # 'bench.png'
|
||||
godot_clean_asset_name("x.png", override="explosion loop") # 'explosion_loop.png'
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al exportar un asset de ComfyUI a Godot, para renombrar el `<prefijo>_NNNNN_.<ext>`
|
||||
a un nombre semántico y seguro (lo usa `comfyui_export_asset_to_godot`). También
|
||||
para limpiar cualquier nombre de archivo antes de meterlo en `res://`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Solo quita el sufijo numérico `_NNNNN_` del **nombre de origen**; con `override`
|
||||
se respetan los dígitos finales (p.ej. `hero_v2` no pierde el `2`).
|
||||
- Reduce cualquier carácter no `[a-z0-9_]` a `_` y colapsa repetidos; un nombre que
|
||||
quede vacío cae a `"asset"`.
|
||||
- Conserva la extensión en minúscula; si el origen no tiene extensión, devuelve solo
|
||||
el nombre sin punto.
|
||||
@@ -0,0 +1,49 @@
|
||||
"""godot_clean_asset_name — normaliza el nombre de archivo de un asset para Godot.
|
||||
|
||||
Funcion pura: toma el nombre de un asset salido de ComfyUI (patron
|
||||
`<prefijo>_NNNNN_.<ext>`, p.ej. `svd_motion_hi_00001_.webp`) y devuelve un nombre
|
||||
limpio, semantico y seguro para `res://`: snake_case, minusculas, sin el sufijo
|
||||
numerico `_NNNNN_`, sin espacios ni caracteres raros, conservando la extension.
|
||||
No toca disco. Pensada para el puente ComfyUI -> Godot (renombrar al exportar).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
_NNNNN_SUFFIX = re.compile(r"_\d{3,}_?$") # sufijo numerico de ComfyUI: _00001_ / _00001
|
||||
_NON_SAFE = re.compile(r"[^a-z0-9_]+") # cualquier cosa que no sea snake_case
|
||||
_MULTI_US = re.compile(r"_{2,}")
|
||||
|
||||
|
||||
def godot_clean_asset_name(filename: str, *, override: str | None = None) -> str:
|
||||
"""Limpia el nombre de un asset a snake_case seguro conservando la extension.
|
||||
|
||||
Args:
|
||||
filename: nombre o ruta del asset de origen (se toma solo el basename).
|
||||
override: nombre base deseado (sin extension); si se pasa, se usa en lugar
|
||||
del nombre de origen (igual se normaliza a snake_case y se le anade la
|
||||
extension del origen). keyword-only.
|
||||
|
||||
Returns:
|
||||
Nombre de archivo limpio "snake_case.ext" (ext en minuscula, sin punto si
|
||||
el origen no tenia extension).
|
||||
"""
|
||||
base = os.path.basename(filename or "")
|
||||
stem, ext = os.path.splitext(base)
|
||||
ext = ext.lower()
|
||||
|
||||
raw = override if override is not None else stem
|
||||
name = raw.strip().lower().replace(" ", "_").replace("-", "_")
|
||||
if override is None:
|
||||
name = _NNNNN_SUFFIX.sub("", name) # quita _00001_ solo del nombre de origen
|
||||
name = _NON_SAFE.sub("_", name)
|
||||
name = _MULTI_US.sub("_", name).strip("_")
|
||||
if not name:
|
||||
name = "asset"
|
||||
return f"{name}{ext}" if ext else name
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
print(godot_clean_asset_name(sys.argv[1] if len(sys.argv) > 1 else "svd_motion_hi_00001_.webp"))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests de godot_clean_asset_name (pura, offline)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.godot_clean_asset_name import godot_clean_asset_name # noqa: E402
|
||||
|
||||
|
||||
def test_strips_comfyui_suffix():
|
||||
assert godot_clean_asset_name("svd_motion_hi_00001_.webp") == "svd_motion_hi.webp"
|
||||
assert godot_clean_asset_name("3d_robot_mesh_00001_.glb") == "3d_robot_mesh.glb"
|
||||
|
||||
|
||||
def test_normalizes_spaces_and_case():
|
||||
assert godot_clean_asset_name("My Hero Sprite.PNG") == "my_hero_sprite.png"
|
||||
assert godot_clean_asset_name("fire-flare.png") == "fire_flare.png"
|
||||
|
||||
|
||||
def test_takes_basename_from_path():
|
||||
assert godot_clean_asset_name("/home/x/ComfyUI/output/bench_00042_.png") == "bench.png"
|
||||
|
||||
|
||||
def test_override_name():
|
||||
assert godot_clean_asset_name("svd_00001_.webp", override="explosion loop") == "explosion_loop.webp"
|
||||
# override conserva digitos finales (no es sufijo ComfyUI a quitar)
|
||||
assert godot_clean_asset_name("x.png", override="hero_v2") == "hero_v2.png"
|
||||
|
||||
|
||||
def test_empty_fallback():
|
||||
assert godot_clean_asset_name("_00001_.png") == "asset.png"
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: godot_map_asset_dir
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def godot_map_asset_dir(kind: str) -> str"
|
||||
description: "Mapea el tipo de asset (sprite, pixelart, tileset, vfx, sfx, music, model) a su subcarpeta canonica bajo res://assets/ de un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot. Pura: solo resuelve una ruta relativa POSIX, no toca disco. kind desconocido -> ValueError."
|
||||
tags: [godot, gamedev-2d, core, assets, mapping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: kind
|
||||
desc: "tipo de asset: 'sprite', 'pixelart', 'tileset', 'vfx', 'sfx', 'music' o 'model' (case-insensitive, se ignoran espacios)."
|
||||
output: "subruta relativa POSIX bajo assets/ (p.ej. 'sprites', 'tilesets', 'audio/music', 'models')."
|
||||
tested: true
|
||||
tests: [test_all_kinds, test_case_insensitive, test_unknown_kind_raises]
|
||||
test_file_path: "python/functions/core/godot_map_asset_dir_test.py"
|
||||
file_path: "python/functions/core/godot_map_asset_dir.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from core.godot_map_asset_dir import godot_map_asset_dir
|
||||
|
||||
godot_map_asset_dir("pixelart") # 'sprites'
|
||||
godot_map_asset_dir("music") # 'audio/music'
|
||||
godot_map_asset_dir("model") # 'models'
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al construir la ruta destino de un asset dentro de un proyecto Godot (lo usa el
|
||||
pipeline `comfyui_export_asset_to_godot`). Centraliza la convención tipo -> carpeta
|
||||
para no esparcir el mapeo por varias funciones.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `pixelart` cae en `sprites/` (el pixelart no es una carpeta, es un atributo: lo
|
||||
que cambia es el filtro Nearest, no la ubicación).
|
||||
- `kind` desconocido lanza `ValueError` (no devuelve un default silencioso).
|
||||
- Devuelve siempre subrutas POSIX (`/`), no rutas absolutas: el llamador antepone
|
||||
`<proyecto>/assets/`.
|
||||
@@ -0,0 +1,46 @@
|
||||
"""godot_map_asset_dir — mapea el tipo de asset a su subcarpeta res://assets/.
|
||||
|
||||
Funcion pura: dado el `kind` de un asset (sprite, pixelart, tileset, vfx, sfx,
|
||||
music, model) devuelve el subdirectorio canonico bajo `res://assets/` donde debe
|
||||
vivir en un proyecto Godot 4, segun la convencion del puente ComfyUI -> Godot
|
||||
(docs/comfyui-godot-integration.md). No toca disco: solo resuelve una ruta
|
||||
relativa. `kind` desconocido -> ValueError.
|
||||
"""
|
||||
|
||||
# kind -> subruta relativa bajo assets/ (convencion del puente ComfyUI->Godot).
|
||||
_KIND_TO_DIR = {
|
||||
"sprite": "sprites",
|
||||
"pixelart": "sprites",
|
||||
"tileset": "tilesets",
|
||||
"vfx": "vfx",
|
||||
"sfx": "audio/sfx",
|
||||
"music": "audio/music",
|
||||
"model": "models",
|
||||
}
|
||||
|
||||
|
||||
def godot_map_asset_dir(kind: str) -> str:
|
||||
"""Resuelve la subcarpeta de assets para un tipo de asset Godot.
|
||||
|
||||
Args:
|
||||
kind: tipo de asset: "sprite", "pixelart", "tileset", "vfx", "sfx",
|
||||
"music" o "model".
|
||||
|
||||
Returns:
|
||||
Subruta relativa (POSIX) bajo `assets/`, p.ej. "sprites" o "audio/music".
|
||||
|
||||
Raises:
|
||||
ValueError: si `kind` no es uno de los tipos soportados.
|
||||
"""
|
||||
key = (kind or "").strip().lower()
|
||||
if key not in _KIND_TO_DIR:
|
||||
raise ValueError(
|
||||
f"kind desconocido: {kind!r}. Soportados: {sorted(_KIND_TO_DIR)}"
|
||||
)
|
||||
return _KIND_TO_DIR[key]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
print(godot_map_asset_dir(sys.argv[1] if len(sys.argv) > 1 else "pixelart"))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests de godot_map_asset_dir (pura, offline)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.godot_map_asset_dir import godot_map_asset_dir # noqa: E402
|
||||
|
||||
|
||||
def test_all_kinds():
|
||||
assert godot_map_asset_dir("sprite") == "sprites"
|
||||
assert godot_map_asset_dir("pixelart") == "sprites"
|
||||
assert godot_map_asset_dir("tileset") == "tilesets"
|
||||
assert godot_map_asset_dir("vfx") == "vfx"
|
||||
assert godot_map_asset_dir("sfx") == "audio/sfx"
|
||||
assert godot_map_asset_dir("music") == "audio/music"
|
||||
assert godot_map_asset_dir("model") == "models"
|
||||
|
||||
|
||||
def test_case_insensitive():
|
||||
assert godot_map_asset_dir("PixelArt") == "sprites"
|
||||
assert godot_map_asset_dir(" MODEL ") == "models"
|
||||
|
||||
|
||||
def test_unknown_kind_raises():
|
||||
with pytest.raises(ValueError):
|
||||
godot_map_asset_dir("hologram")
|
||||
@@ -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,79 @@
|
||||
---
|
||||
name: comfyui_append_styles
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_append_styles(new_styles: dict, styles_path: str = DEFAULT_STYLES_PATH, overwrite: bool = False, backup: bool = True, dry_run: bool = False) -> dict"
|
||||
description: "Fusiona (merge+dedup) un dict de estilos nuevos sobre el styles.json del selector WAS de ComfyUI (Prompt Styles Selector / Prompt Multiple Styles Selector) de forma SEGURA y NO destructiva. Preserva TODOS los estilos existentes (dedup por nombre; los existentes ganan salvo overwrite=True), hace backup con timestamp antes de escribir, valida cada entrada nueva (descarta las que no tengan prompt no vacio, rellena negative_prompt por defecto si falta) y escribe de forma atomica (.tmp + os.replace). Devuelve un resumen con conteos antes/despues, anadidos, duplicados saltados e invalidos para verificar el efecto sin releer el archivo. Impura: lee y escribe disco; no usa red, no borra el original."
|
||||
tags: [comfyui, ml, comfyui-styles, styles, was, merge, dedup]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: new_styles
|
||||
desc: "dict {nombre: {'prompt': str, 'negative_prompt': str}} de estilos a anadir. Entradas sin prompt no vacio se descartan; las que no traen negative_prompt reciben uno por defecto. Debe ser un dict (si no, ValueError)."
|
||||
- name: styles_path
|
||||
desc: "Ruta del styles.json. Default: ~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json (la instalacion WAS del usuario). Debe existir (no se crea de cero: FileNotFoundError)."
|
||||
- name: overwrite
|
||||
desc: "Si False (default), un nombre que ya existe NO se pisa (se cuenta como skipped_existing). Si True, los nuevos pisan a los existentes (overwritten)."
|
||||
- name: backup
|
||||
desc: "Si True (default), copia el archivo a <path>.bak.<epoch> antes de escribir. Backup hecho ANTES de tocar el original."
|
||||
- name: dry_run
|
||||
desc: "Si True, calcula el merge y los conteos pero NO escribe nada (ni backup). Para previsualizar el efecto."
|
||||
output: "dict resumen: {styles_path, backup_path, total_before, total_after, added:[nombres], overwritten:[nombres], skipped_existing:[nombres], invalid:[nombres], dry_run:bool}."
|
||||
tested: true
|
||||
tests: ["golden: merge preserva A y B existentes y anade C; total_before 2 -> total_after 3", "edge dedup: nombre existente no se pisa por defecto (skipped_existing), el original se conserva", "edge overwrite=True pisa el existente", "edge negative por defecto cuando la entrada nueva no lo trae", "edge entradas invalidas (no dict, prompt vacio, sin prompt) se descartan a invalid", "edge backup creado con el estado anterior", "error/edge dry_run no escribe el archivo (intacto) pero calcula conteos"]
|
||||
test_file_path: "python/functions/ml/comfyui_append_styles_test.py"
|
||||
file_path: "python/functions/ml/comfyui_append_styles.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_append_styles import comfyui_append_styles
|
||||
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||
|
||||
# Fusionar el catalogo curado sobre el styles.json real, preservando los existentes.
|
||||
nuevos = comfyui_curated_styles_catalog() # 190 estilos curados
|
||||
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura atomica
|
||||
print(res["total_before"], "->", res["total_after"], "anadidos:", len(res["added"]))
|
||||
# 269 -> 459 anadidos: 190 (los duplicados por nombre quedan en skipped_existing)
|
||||
|
||||
# Previsualizar sin escribir:
|
||||
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"])
|
||||
```
|
||||
|
||||
O por CLI: `echo '{"x":{"prompt":"neon glow"}}' | python/.venv/bin/python3 python/functions/ml/comfyui_append_styles.py --dry-run`
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras AMPLIAR el repositorio de estilos del selector WAS de ComfyUI sin perder los que
|
||||
ya hay. Es el paso de escritura del flujo "generar estilos -> fusionar": genera un dict de estilos
|
||||
(con `comfyui_curated_styles_catalog` y/o `comfyui_generate_styles_llm`), pasalo aqui y el archivo
|
||||
queda fusionado con backup. Usala SIEMPRE en vez de editar el JSON a mano (preserva los existentes,
|
||||
valida formato, hace backup atomico). Tras escribir, reinicia `comfyui.service` para que el
|
||||
selector recargue el catalogo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Reinicio necesario**: el nodo WAS lee styles.json al arrancar. Despues de fusionar hay que
|
||||
`systemctl --user restart comfyui.service` (o reiniciar el server) para que el selector liste
|
||||
los nuevos. Verifica con `GET /object_info` contando los enum del `Prompt Styles Selector`.
|
||||
- **dedup por NOMBRE, no por contenido**: dos estilos con el mismo nombre se consideran el mismo;
|
||||
por defecto gana el existente. Si quieres reemplazar deliberadamente, pasa `overwrite=True`.
|
||||
- **El archivo debe existir**: no se crea de cero (FileNotFoundError) para no enmascarar una
|
||||
instalacion WAS rota. Si lo necesitas vacio, crea `{}` a mano primero.
|
||||
- **Backups se acumulan**: cada escritura deja un `styles.json.bak.<epoch>`. Limpialos a mano si
|
||||
molestan; son la red de seguridad para restaurar.
|
||||
- **No versionar**: el styles.json es de ComfyUI, no de fn_registry. No hacer `git add` de el.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(v1.0.0 — sin cambios todavia.)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""comfyui_append_styles — merge+dedup de estilos nuevos sobre el styles.json de WAS.
|
||||
|
||||
El selector de estilos de ComfyUI (nodos WAS `Prompt Styles Selector` /
|
||||
`Prompt Multiple Styles Selector`) lee de
|
||||
`~/ComfyUI/custom_nodes/was-node-suite-comfyui/styles.json`, un dict cuyo formato exacto es:
|
||||
|
||||
{ "NombreEstilo": {"prompt": "modificadores de estilo", "negative_prompt": "..."}, ... }
|
||||
|
||||
El selector múltiple CONCATENA los `prompt` de los estilos elegidos, por lo que cada `prompt`
|
||||
debe contener MODIFICADORES de estilo (no la descripción del sujeto) y NO el placeholder
|
||||
`{prompt}`.
|
||||
|
||||
Esta función fusiona un dict de estilos nuevos sobre el archivo existente de forma SEGURA y NO
|
||||
destructiva:
|
||||
|
||||
- Hace un backup con timestamp del styles.json antes de tocarlo (nunca sobrescribe sin copia).
|
||||
- Preserva TODOS los estilos existentes (dedup por nombre: los existentes ganan salvo
|
||||
`overwrite=True`).
|
||||
- Valida cada entrada nueva: debe ser un dict con `prompt` no vacío. Si falta `negative_prompt`
|
||||
se rellena con un negativo por defecto razonable; las entradas inválidas se descartan
|
||||
(reportadas, no abortan el merge).
|
||||
- Escribe el resultado de forma atómica (a un .tmp y `os.replace`).
|
||||
|
||||
Devuelve un resumen con conteos (antes/después, añadidos, duplicados saltados, inválidos) para
|
||||
que el caller verifique el efecto sin volver a leer el archivo.
|
||||
|
||||
Impura: lee y escribe disco. No usa red. No mata procesos. No borra el original (sólo backup +
|
||||
reemplazo atómico).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
# Negativo por defecto cuando un estilo nuevo no trae `negative_prompt`. Sobrio y SFW,
|
||||
# alineado con el estilo de los negativos que ya viven en el styles.json de WAS.
|
||||
DEFAULT_NEGATIVE = (
|
||||
"ugly, deformed, noisy, blurry, low quality, distorted, disfigured, "
|
||||
"bad anatomy, watermark, signature, text, NSFW"
|
||||
)
|
||||
|
||||
DEFAULT_STYLES_PATH = os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
"ComfyUI",
|
||||
"custom_nodes",
|
||||
"was-node-suite-comfyui",
|
||||
"styles.json",
|
||||
)
|
||||
|
||||
|
||||
def _validate_entry(value: object) -> dict | None:
|
||||
"""Normaliza una entrada de estilo. Devuelve el dict válido o None si es inválida.
|
||||
|
||||
Una entrada válida es un dict con `prompt` (str no vacío). `negative_prompt` se rellena
|
||||
con `DEFAULT_NEGATIVE` si falta o está vacío. Campos extra se descartan (el formato WAS
|
||||
sólo usa `prompt` y `negative_prompt`).
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
prompt = value.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
return None
|
||||
neg = value.get("negative_prompt")
|
||||
if not isinstance(neg, str) or not neg.strip():
|
||||
neg = DEFAULT_NEGATIVE
|
||||
return {"prompt": prompt.strip(), "negative_prompt": neg.strip()}
|
||||
|
||||
|
||||
def comfyui_append_styles(
|
||||
new_styles: dict,
|
||||
styles_path: str = DEFAULT_STYLES_PATH,
|
||||
overwrite: bool = False,
|
||||
backup: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Fusiona `new_styles` sobre el styles.json de WAS preservando los existentes.
|
||||
|
||||
Args:
|
||||
new_styles: dict {nombre: {"prompt": str, "negative_prompt": str}} de estilos a añadir.
|
||||
Las entradas inválidas (sin `prompt`) se descartan; las que no traen
|
||||
`negative_prompt` reciben uno por defecto.
|
||||
styles_path: ruta del styles.json. Default: el de la instalación WAS del usuario.
|
||||
overwrite: si False (default), un nombre que ya existe en el archivo NO se pisa (se
|
||||
cuenta como duplicado saltado). Si True, los nuevos pisan a los existentes.
|
||||
backup: si True (default), copia el archivo a `<path>.bak.<epoch>` antes de escribir.
|
||||
dry_run: si True, calcula el merge y los conteos pero NO escribe nada (ni backup).
|
||||
|
||||
Returns:
|
||||
dict resumen: {
|
||||
"styles_path", "backup_path", "total_before", "total_after",
|
||||
"added": [nombres añadidos], "overwritten": [nombres pisados],
|
||||
"skipped_existing": [nombres saltados por existir y overwrite=False],
|
||||
"invalid": [nombres descartados por inválidos], "dry_run": bool
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si `styles_path` no existe (no se crea de cero para no enmascarar
|
||||
una instalación rota; el caller debe asegurar que el archivo está).
|
||||
ValueError: si `new_styles` no es un dict o el archivo existente no contiene un dict.
|
||||
"""
|
||||
if not isinstance(new_styles, dict):
|
||||
raise ValueError("comfyui_append_styles: new_styles debe ser un dict")
|
||||
if not os.path.isfile(styles_path):
|
||||
raise FileNotFoundError(f"comfyui_append_styles: no existe styles.json en {styles_path!r}")
|
||||
|
||||
with open(styles_path, "r", encoding="utf-8") as fh:
|
||||
existing = json.load(fh)
|
||||
if not isinstance(existing, dict):
|
||||
raise ValueError(
|
||||
f"comfyui_append_styles: el styles.json en {styles_path!r} no es un dict de estilos"
|
||||
)
|
||||
|
||||
total_before = len(existing)
|
||||
merged = dict(existing) # copia: no mutar el cargado hasta validar todo
|
||||
|
||||
added: list[str] = []
|
||||
overwritten: list[str] = []
|
||||
skipped_existing: list[str] = []
|
||||
invalid: list[str] = []
|
||||
|
||||
for name, value in new_styles.items():
|
||||
norm = _validate_entry(value)
|
||||
if norm is None:
|
||||
invalid.append(str(name))
|
||||
continue
|
||||
if name in existing:
|
||||
if overwrite:
|
||||
merged[name] = norm
|
||||
overwritten.append(name)
|
||||
else:
|
||||
skipped_existing.append(name)
|
||||
continue
|
||||
merged[name] = norm
|
||||
added.append(name)
|
||||
|
||||
backup_path = ""
|
||||
if not dry_run:
|
||||
if backup:
|
||||
backup_path = f"{styles_path}.bak.{int(time.time())}"
|
||||
shutil.copy2(styles_path, backup_path)
|
||||
# Escritura atómica: escribir a .tmp en el mismo dir y reemplazar.
|
||||
tmp_path = f"{styles_path}.tmp.{os.getpid()}"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(merged, fh, ensure_ascii=False, indent=4)
|
||||
os.replace(tmp_path, styles_path)
|
||||
|
||||
return {
|
||||
"styles_path": styles_path,
|
||||
"backup_path": backup_path,
|
||||
"total_before": total_before,
|
||||
"total_after": len(merged),
|
||||
"added": added,
|
||||
"overwritten": overwritten,
|
||||
"skipped_existing": skipped_existing,
|
||||
"invalid": invalid,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# CLI de conveniencia: lee un dict de estilos JSON de stdin (o de un archivo dado como
|
||||
# primer arg) y lo fusiona. Con --dry-run no escribe. Imprime el resumen como JSON.
|
||||
args = sys.argv[1:]
|
||||
dry = "--dry-run" in args
|
||||
over = "--overwrite" in args
|
||||
path_args = [a for a in args if not a.startswith("--")]
|
||||
if path_args:
|
||||
with open(path_args[0], "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
else:
|
||||
payload = json.load(sys.stdin)
|
||||
res = comfyui_append_styles(payload, overwrite=over, dry_run=dry)
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests offline de comfyui_append_styles — no toca la instalación real ni la red.
|
||||
|
||||
Usa un styles.json temporal en /tmp para validar merge, dedup, backup, validación y dry-run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from comfyui_append_styles import comfyui_append_styles, DEFAULT_NEGATIVE
|
||||
|
||||
|
||||
def _write_styles(tmpdir: str, data: dict) -> str:
|
||||
path = os.path.join(tmpdir, "styles.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=4)
|
||||
return path
|
||||
|
||||
|
||||
def test_merge_preserva_existentes_y_anade_nuevos():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {
|
||||
"A": {"prompt": "a-style", "negative_prompt": "neg-a"},
|
||||
"B": {"prompt": "b-style", "negative_prompt": "neg-b"},
|
||||
})
|
||||
res = comfyui_append_styles(
|
||||
{"C": {"prompt": "c-style", "negative_prompt": "neg-c"}},
|
||||
styles_path=path,
|
||||
)
|
||||
assert res["total_before"] == 2
|
||||
assert res["total_after"] == 3
|
||||
assert res["added"] == ["C"]
|
||||
loaded = json.load(open(path, encoding="utf-8"))
|
||||
# Los existentes intactos.
|
||||
assert loaded["A"] == {"prompt": "a-style", "negative_prompt": "neg-a"}
|
||||
assert loaded["B"] == {"prompt": "b-style", "negative_prompt": "neg-b"}
|
||||
assert loaded["C"] == {"prompt": "c-style", "negative_prompt": "neg-c"}
|
||||
|
||||
|
||||
def test_dedup_no_pisa_por_defecto():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}})
|
||||
res = comfyui_append_styles(
|
||||
{"A": {"prompt": "NUEVO", "negative_prompt": "n2"}},
|
||||
styles_path=path,
|
||||
)
|
||||
assert res["skipped_existing"] == ["A"]
|
||||
assert res["added"] == []
|
||||
assert res["total_after"] == 1
|
||||
loaded = json.load(open(path, encoding="utf-8"))
|
||||
assert loaded["A"]["prompt"] == "orig" # preservado
|
||||
|
||||
|
||||
def test_overwrite_si_se_pide():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {"A": {"prompt": "orig", "negative_prompt": "n"}})
|
||||
res = comfyui_append_styles(
|
||||
{"A": {"prompt": "NUEVO", "negative_prompt": "n2"}},
|
||||
styles_path=path,
|
||||
overwrite=True,
|
||||
)
|
||||
assert res["overwritten"] == ["A"]
|
||||
loaded = json.load(open(path, encoding="utf-8"))
|
||||
assert loaded["A"]["prompt"] == "NUEVO"
|
||||
|
||||
|
||||
def test_negative_por_defecto_cuando_falta():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {})
|
||||
res = comfyui_append_styles(
|
||||
{"X": {"prompt": "solo-prompt"}}, # sin negative_prompt
|
||||
styles_path=path,
|
||||
)
|
||||
assert res["added"] == ["X"]
|
||||
loaded = json.load(open(path, encoding="utf-8"))
|
||||
assert loaded["X"]["negative_prompt"] == DEFAULT_NEGATIVE
|
||||
|
||||
|
||||
def test_entradas_invalidas_se_descartan():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {})
|
||||
res = comfyui_append_styles(
|
||||
{
|
||||
"ok": {"prompt": "valido"},
|
||||
"vacio": {"prompt": " "}, # prompt vacío
|
||||
"no_dict": "string", # no es dict
|
||||
"sin_prompt": {"negative_prompt": "n"},
|
||||
},
|
||||
styles_path=path,
|
||||
)
|
||||
assert res["added"] == ["ok"]
|
||||
assert set(res["invalid"]) == {"vacio", "no_dict", "sin_prompt"}
|
||||
assert res["total_after"] == 1
|
||||
|
||||
|
||||
def test_backup_creado():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}})
|
||||
res = comfyui_append_styles(
|
||||
{"B": {"prompt": "b"}},
|
||||
styles_path=path,
|
||||
)
|
||||
assert res["backup_path"]
|
||||
assert os.path.isfile(res["backup_path"])
|
||||
# El backup contiene el estado ANTERIOR (sólo A).
|
||||
bk = json.load(open(res["backup_path"], encoding="utf-8"))
|
||||
assert list(bk) == ["A"]
|
||||
|
||||
|
||||
def test_dry_run_no_escribe():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write_styles(d, {"A": {"prompt": "a", "negative_prompt": "n"}})
|
||||
before = open(path, encoding="utf-8").read()
|
||||
res = comfyui_append_styles(
|
||||
{"B": {"prompt": "b"}},
|
||||
styles_path=path,
|
||||
dry_run=True,
|
||||
)
|
||||
assert res["dry_run"] is True
|
||||
assert res["added"] == ["B"]
|
||||
assert res["total_after"] == 2 # calculado
|
||||
assert res["backup_path"] == ""
|
||||
after = open(path, encoding="utf-8").read()
|
||||
assert before == after # archivo intacto
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
fn()
|
||||
print("PASS", name)
|
||||
print("OK")
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: comfyui_apply_style_preset
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_apply_style_preset(preset: dict, subject: str, *, style: str | None = None, negative: str | None = None) -> dict"
|
||||
description: "Traduce un STYLE PRESET gamedev (de comfyui_get_gamedev_style_preset) + un subject del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo, los kwargs comunes (style, checkpoint, lora, lora_strength, negative) listos para **spread, la resolucion y el recorte recomendados (size, transparent) y la spec de post-proceso (post, p.ej. pixelize) que el caller aplica al PNG. Asi el mismo estilo se aplica a CUALQUIER builder (item_icon, enemy_creature, prop_object, ...) y al pipeline comfyui_generate_asset_pack_oneshot sin acoplar firmas. Pura, sin red ni I/O; no muta el preset."
|
||||
tags: [comfyui, ml, gamedev-2d, style, preset, theme]
|
||||
uses_functions: [comfyui_get_gamedev_style_preset_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: preset
|
||||
desc: "Receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer los campos del preset (se valida que esten). No se muta."
|
||||
- name: subject
|
||||
desc: "Lo que el usuario quiere generar (ej. 'knight character', 'health potion'). Se combina con el prefijo/sufijo del estilo. No puede estar vacio."
|
||||
- name: style
|
||||
desc: "Override puntual: si se pasa, sustituye al style del preset. None usa el del preset. keyword-only."
|
||||
- name: negative
|
||||
desc: "Negativo extra del caller; se MERGEA (sin duplicar) con el negativo del estilo, no lo reemplaza. None = solo el del estilo. keyword-only."
|
||||
output: "dict con: name (estilo aplicado), subject (combinado con prefijo/sufijo), builder_kwargs ({style, checkpoint, lora, lora_strength, negative} para **spread en el builder), size (resolucion recomendada), transparent (recorte recomendado), post (post-proceso CPU: {'pixelize': {...}} o {})."
|
||||
tested: true
|
||||
tests: ["golden gameboy: subject combina suffix (8-bit), builder_kwargs con las 5 claves comunes, checkpoint dreamshaper, lora None, post pixelize paleta game-boy", "golden contrato: los builder_kwargs hacen **spread en comfyui_build_item_icon_workflow sin TypeError y el LoRA del preset aparece en el grafo", "edge style override sustituye el del preset", "edge negative se mergea con el del estilo (no se pierde photorealistic) y deduplica", "edge no muta el preset de entrada", "error subject vacio -> ValueError", "error preset incompleto -> ValueError"]
|
||||
test_file_path: "python/functions/ml/comfyui_apply_style_preset_test.py"
|
||||
file_path: "python/functions/ml/comfyui_apply_style_preset.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||
|
||||
# 1. Elegir estilo + aplicarlo a un subject
|
||||
preset = comfyui_get_gamedev_style_preset("gameboy")
|
||||
ap = comfyui_apply_style_preset(preset, "knight character")
|
||||
|
||||
# 2. Construir el workflow con cualquier builder de sujeto (kwargs por **spread)
|
||||
wf = comfyui_build_enemy_creature_workflow(
|
||||
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||
)
|
||||
# 3. Generar (submit/wait/fetch) y, si el estilo lo pide, post-proceso:
|
||||
# if ap["post"].get("pixelize"):
|
||||
# comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"])
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_apply_style_preset` (aplica pixel-art-retro a "knight character").
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Justo despues de elegir un estilo con `comfyui_get_gamedev_style_preset`, para convertir
|
||||
esa receta en los argumentos exactos de un builder. Es el puente entre "que estilo quiero"
|
||||
y "como lo paso a item_icon/enemy_creature/prop_object/...". El mismo `ap` sirve para
|
||||
generar N assets distintos en el MISMO estilo (varia solo el `subject`). Para overrides
|
||||
puntuales sin tocar el preset, usa `style=`/`negative=`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Devuelve `builder_kwargs` con EXACTAMENTE las 5 claves comunes a los builders de SUJETO
|
||||
(`style`, `checkpoint`, `lora`, `lora_strength`, `negative`). Builders que NO las acepten
|
||||
todas (p.ej. `seamless_tile`, `parallax_background` no tienen `transparent`/`lora` igual)
|
||||
exigen filtrar las claves; este helper esta pensado para los builders de sujeto cuadrado.
|
||||
- `size` y `transparent` van FUERA de `builder_kwargs` (son recomendaciones del estilo): el
|
||||
caller los pasa explicitos o decide otros. `transparent=False` en los presets de demo es
|
||||
para que el look (paleta/pintura) cubra todo el frame; para un sprite con alpha pon
|
||||
`transparent=True` (el recorte es ortogonal al estilo).
|
||||
- El `post` NO se aplica solo: el caller debe llamar `comfyui_pixelize_image(raw, dst,
|
||||
**ap["post"]["pixelize"])` tras descargar el PNG si `ap["post"].get("pixelize")`. Sin eso,
|
||||
estilos como gameboy/pixel-art-retro no sellan su grid/paleta.
|
||||
- Es **pura**: no llama a ningun builder ni toca la GPU; solo arma kwargs. No muta el
|
||||
`preset` de entrada (lo que devuelve es independiente).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(v1.0.0 — sin cambios todavia.)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""comfyui_apply_style_preset — traduce un style preset gamedev a los kwargs de un builder.
|
||||
|
||||
Toma una receta de estilo (de `comfyui_get_gamedev_style_preset`) y un `subject` del usuario
|
||||
y produce, de forma PURA, lo que un builder de sujeto del grupo `gamedev-2d` necesita:
|
||||
|
||||
- el `subject` combinado con el prefijo/sufijo del estilo,
|
||||
- los kwargs comunes a todos los builders de sujeto (`style`, `checkpoint`, `lora`,
|
||||
`lora_strength`, `negative`) listos para hacer `**spread`,
|
||||
- la resolucion y el recorte recomendados (`size`, `transparent`),
|
||||
- y la spec de post-proceso (`post`, p.ej. pixelize) que el caller aplica al PNG resultante.
|
||||
|
||||
Asi el mismo estilo se aplica a CUALQUIER builder de sujeto (item_icon, enemy_creature,
|
||||
prop_object, structure, ...) sin acoplar este helper a sus firmas, y el preset elige el
|
||||
checkpoint/lora coherentes ANTES de construir el grafo.
|
||||
|
||||
Patron de uso:
|
||||
|
||||
preset = comfyui_get_gamedev_style_preset("gameboy")
|
||||
ap = comfyui_apply_style_preset(preset, "knight character")
|
||||
wf = comfyui_build_enemy_creature_workflow(
|
||||
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||
)
|
||||
# tras submit/wait/fetch, si ap["post"].get("pixelize"):
|
||||
# comfyui_pixelize_image(raw_png, dst_png, **ap["post"]["pixelize"])
|
||||
|
||||
Funcion pura: sin red, sin I/O. No muta el preset de entrada (copia lo que devuelve).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
# Claves obligatorias de una receta valida (las que produce comfyui_get_gamedev_style_preset).
|
||||
_REQUIRED = (
|
||||
"name",
|
||||
"subject_prefix",
|
||||
"subject_suffix",
|
||||
"style",
|
||||
"negative",
|
||||
"checkpoint",
|
||||
"lora",
|
||||
"lora_strength",
|
||||
"size",
|
||||
"transparent",
|
||||
"post",
|
||||
)
|
||||
|
||||
|
||||
def _merge_negative(a: str, b: str) -> str:
|
||||
"""Une dos negativos por comas sin duplicar terminos ni dejar comas sueltas."""
|
||||
seen: list[str] = []
|
||||
for chunk in (a or "", b or ""):
|
||||
for term in chunk.split(","):
|
||||
t = term.strip()
|
||||
if t and t.lower() not in {s.lower() for s in seen}:
|
||||
seen.append(t)
|
||||
return ", ".join(seen)
|
||||
|
||||
|
||||
def comfyui_apply_style_preset(
|
||||
preset: dict,
|
||||
subject: str,
|
||||
*,
|
||||
style: str | None = None,
|
||||
negative: str | None = None,
|
||||
) -> dict:
|
||||
"""Aplica un style preset a un subject y devuelve los kwargs listos para un builder.
|
||||
|
||||
Args:
|
||||
preset: receta de estilo (dict de comfyui_get_gamedev_style_preset). Debe traer
|
||||
los campos del preset; se valida que esten presentes. No se muta.
|
||||
subject: lo que el usuario quiere generar (ej. "knight character", "health potion").
|
||||
Se combina con el prefijo/sufijo del estilo. No puede estar vacio.
|
||||
style: si se pasa, sustituye al `style` del preset (override puntual). None usa el
|
||||
del preset. keyword-only.
|
||||
negative: negativo extra del caller; se MERGEA con el negativo del estilo (no lo
|
||||
reemplaza). None = solo el del estilo. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- "name" (str): nombre del estilo aplicado.
|
||||
- "subject" (str): subject combinado con prefijo/sufijo del estilo.
|
||||
- "builder_kwargs" (dict): {style, checkpoint, lora, lora_strength, negative} —
|
||||
los kwargs comunes a los builders de sujeto, para hacer **spread.
|
||||
- "size" (int): resolucion recomendada por el estilo.
|
||||
- "transparent" (bool): recorte a alpha recomendado por el estilo.
|
||||
- "post" (dict): post-proceso CPU a aplicar al PNG ({"pixelize": {...}} o {}).
|
||||
|
||||
Raises:
|
||||
ValueError: si subject esta vacio o el preset no trae los campos requeridos.
|
||||
"""
|
||||
if not subject or not subject.strip():
|
||||
raise ValueError("comfyui_apply_style_preset: 'subject' no puede estar vacio")
|
||||
if not isinstance(preset, dict):
|
||||
raise ValueError("comfyui_apply_style_preset: 'preset' debe ser un dict")
|
||||
missing = [k for k in _REQUIRED if k not in preset]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"comfyui_apply_style_preset: preset incompleto, faltan campos {missing}. "
|
||||
"Usa comfyui_get_gamedev_style_preset para obtener una receta valida."
|
||||
)
|
||||
|
||||
subject_full = (
|
||||
f"{preset['subject_prefix']}{subject.strip()}{preset['subject_suffix']}"
|
||||
).strip().strip(",").strip()
|
||||
|
||||
style_final = style if style is not None else preset["style"]
|
||||
neg_final = _merge_negative(preset["negative"], negative or "")
|
||||
|
||||
return {
|
||||
"name": preset["name"],
|
||||
"subject": subject_full,
|
||||
"builder_kwargs": {
|
||||
"style": style_final,
|
||||
"checkpoint": preset["checkpoint"],
|
||||
"lora": preset["lora"],
|
||||
"lora_strength": preset["lora_strength"],
|
||||
"negative": neg_final,
|
||||
},
|
||||
"size": preset["size"],
|
||||
"transparent": preset["transparent"],
|
||||
"post": copy.deepcopy(preset["post"]),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||
|
||||
p = comfyui_get_gamedev_style_preset("pixel-art-retro")
|
||||
ap = comfyui_apply_style_preset(p, "knight character")
|
||||
print(json.dumps(ap, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests offline de comfyui_apply_style_preset (traduccion preset -> kwargs, sin GPU)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset # noqa: E402
|
||||
from ml.comfyui_get_gamedev_style_preset import ( # noqa: E402
|
||||
comfyui_get_gamedev_style_preset,
|
||||
)
|
||||
|
||||
|
||||
def test_golden_apply_gameboy_to_subject():
|
||||
p = comfyui_get_gamedev_style_preset("gameboy")
|
||||
ap = comfyui_apply_style_preset(p, "knight character")
|
||||
# El subject combina prefijo/sufijo del estilo.
|
||||
assert "knight character" in ap["subject"]
|
||||
assert "8-bit" in ap["subject"] # del subject_suffix del gameboy
|
||||
# builder_kwargs trae las claves comunes a los builders de sujeto, listas para **spread.
|
||||
bk = ap["builder_kwargs"]
|
||||
assert set(bk) == {"style", "checkpoint", "lora", "lora_strength", "negative"}
|
||||
assert bk["checkpoint"] == "IMG_dreamshaper_8.safetensors"
|
||||
assert bk["lora"] is None
|
||||
assert "Game Boy" in bk["style"]
|
||||
# Recomendaciones y post propagados.
|
||||
assert ap["transparent"] is False
|
||||
assert ap["post"]["pixelize"]["palette"] == "game-boy"
|
||||
|
||||
|
||||
def test_golden_kwargs_spreadable_into_builder():
|
||||
# Los builder_kwargs son exactamente los que aceptan los builders de sujeto:
|
||||
# se hace **spread sin TypeError (verifica el contrato con item_icon).
|
||||
from ml.comfyui_build_item_icon_workflow import comfyui_build_item_icon_workflow
|
||||
|
||||
p = comfyui_get_gamedev_style_preset("ghibli")
|
||||
ap = comfyui_apply_style_preset(p, "magic potion")
|
||||
wf = comfyui_build_item_icon_workflow(
|
||||
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||
)
|
||||
cls = sorted({n["class_type"] for n in wf.values()})
|
||||
assert "KSampler" in cls
|
||||
# El LoRA watercolor del preset aparece en el grafo.
|
||||
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
|
||||
assert loras and loras[0]["inputs"]["lora_name"] == "SD15_watercolor_style.safetensors"
|
||||
|
||||
|
||||
def test_edge_style_override():
|
||||
p = comfyui_get_gamedev_style_preset("gameboy")
|
||||
ap = comfyui_apply_style_preset(p, "tree", style="custom override style")
|
||||
assert ap["builder_kwargs"]["style"] == "custom override style"
|
||||
|
||||
|
||||
def test_edge_negative_merged_not_replaced():
|
||||
p = comfyui_get_gamedev_style_preset("gameboy")
|
||||
ap = comfyui_apply_style_preset(p, "tree", negative="extra unwanted thing")
|
||||
neg = ap["builder_kwargs"]["negative"]
|
||||
assert "extra unwanted thing" in neg
|
||||
assert "photorealistic" in neg # del negativo del estilo, no se pierde
|
||||
|
||||
|
||||
def test_edge_negative_dedup():
|
||||
p = comfyui_get_gamedev_style_preset("gameboy")
|
||||
# "photo" ya esta en el negativo del estilo; no debe duplicarse.
|
||||
ap = comfyui_apply_style_preset(p, "tree", negative="photo")
|
||||
assert ap["builder_kwargs"]["negative"].lower().count("photo,") + \
|
||||
ap["builder_kwargs"]["negative"].lower().endswith("photo") <= 2
|
||||
|
||||
|
||||
def test_edge_does_not_mutate_preset():
|
||||
p = comfyui_get_gamedev_style_preset("pixel-art-retro")
|
||||
before = dict(p)
|
||||
ap = comfyui_apply_style_preset(p, "knight")
|
||||
ap["post"]["pixelize"]["colors"] = 999 # mutar el resultado
|
||||
assert p == before # el preset original intacto
|
||||
assert p["post"]["pixelize"]["colors"] == 16
|
||||
|
||||
|
||||
def test_error_empty_subject():
|
||||
p = comfyui_get_gamedev_style_preset("gameboy")
|
||||
try:
|
||||
comfyui_apply_style_preset(p, " ")
|
||||
assert False, "deberia lanzar ValueError"
|
||||
except ValueError as e:
|
||||
assert "subject" in str(e)
|
||||
|
||||
|
||||
def test_error_incomplete_preset():
|
||||
try:
|
||||
comfyui_apply_style_preset({"name": "broken"}, "knight")
|
||||
assert False, "deberia lanzar ValueError"
|
||||
except ValueError as e:
|
||||
assert "incompleto" in str(e) or "faltan" in str(e)
|
||||
@@ -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="IMG_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="IMG_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,140 @@
|
||||
---
|
||||
name: comfyui_build_achievement_badge_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_achievement_badge_workflow(badge: str, *, tier: str = \"gold\", style: str = \"game achievement badge, ornate\", checkpoint: str = \"IMG_dreamshaper_8.safetensors\", size: int = 256, transparent: bool = True, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, rembg_model: str = \"u2net\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"achievement_badge\") -> dict"
|
||||
description: "Construye el dict (API format) del workflow de UNA insignia / medalla / logro 2D (achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro, una estrella o un badge de rango que la UI de achievements pinta cuando el jugador desbloquea un hito, con TIER metalico (bronce / plata / oro / platino / diamante) que distingue el grado. Centrado, fondo limpio uniforme, recortable a alpha, estilo consistente entre insignias del set. DISTINTO de item_icon (objeto de inventario suelto, sin tier ni cinta), status_effect_icon (simbolo de estado superpuesto sin marco) y skill_tree_node (nodo enmarcado de la rejilla de talentos con estado unlocked/locked): esto es la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con cinta + tier. El tier metalico y la forma de medalla/trofeo son la firma del asset. Compone comfyui_build_txt2img_workflow + comfyui_inject_lora (estilo opcional) + Image Rembg (fondo transparente si transparent). Hermano de comfyui_build_item_icon/skill_tree_node_workflow. Pura, sin red ni I/O. class_types verificados contra /object_info."
|
||||
tags: [comfyui, ml, gamedev-2d, ui, achievement, badge, medal, trophy, reward, tier, ribbon, rembg, workflow]
|
||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_lora_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: badge
|
||||
desc: "Nombre / tema del logro que representa la insignia (ej. 'dragon slayer', 'first blood', '100 wins', 'explorer', 'marathon runner', 'boss killer'). Se inserta en un prompt scaffold de insignia. No puede estar vacio."
|
||||
- name: tier
|
||||
desc: "Grado del logro: 'bronze', 'silver', 'gold' (por defecto), 'platinum' o 'diamond'. Define el aspecto metalico de la medalla. Genera la familia con el mismo badge/style/seed variando solo el tier para tener las caras coherentes del mismo logro. Cualquier otro valor se inserta literal y la pista metalica cae a la de 'gold'. keyword-only."
|
||||
- name: style
|
||||
desc: "Descriptor de estilo que mantiene consistentes las insignias de un set (ej. 'game achievement badge, ornate', 'flat minimal medal', 'pixel art trophy'). Pasa el MISMO style + checkpoint + lora a todas las insignias del set para coherencia visual. keyword-only."
|
||||
- name: checkpoint
|
||||
desc: "Checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto; 'IMG_juggernaut_xl_v11.safetensors' para SDXL (mas VRAM, subir size). keyword-only."
|
||||
- name: size
|
||||
desc: "Lado del cuadrado en px (width = height = size). 256 por defecto: las insignias de logro se muestran a tamano reducido en el panel. keyword-only."
|
||||
- name: transparent
|
||||
desc: "Si True inyecta Image Rembg y el PNG sale con alpha (fondo recortado, la silueta de la medalla + cinta). False = insignia opaca sobre fondo plano, recortable luego por el caller. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. Fija el mismo seed en la familia de tiers del mismo logro para que coincidan en composicion. keyword-only."
|
||||
- name: lora
|
||||
desc: "LoRA de estilo opcional en models/loras (ej. 'SD15_detail_tweaker.safetensors'). None = sin LoRA. keyword-only."
|
||||
- name: lora_strength
|
||||
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
|
||||
- name: rembg_model
|
||||
desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo se usa si transparent=True. keyword-only."
|
||||
- name: negative
|
||||
desc: "Prompt negativo. None usa el negativo por defecto pensado para insignias (una medalla limpia, fondo limpio, sin escena/personaje/texto). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "CFG del KSampler. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Sampler del KSampler. keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del KSampler. keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG en output/. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: base txt2img cuadrada con prompt scaffold de insignia ('{badge} achievement badge, {tier} tier ({tier hint}), {style}, medal with ribbon, centered, plain background, game UI reward, ...') + LoRA de estilo opcional + Image Rembg (si transparent). UNA insignia; un set de logros -> llamar por badge (y por tier) con mismo style/checkpoint/lora y montar el panel en el motor."
|
||||
tested: true
|
||||
test_file_path: python/functions/ml/comfyui_build_achievement_badge_workflow_test.py
|
||||
tests: [test_golden_transparent, test_edge_transparent_false_no_rembg, test_edge_size_reflected_square, test_edge_tier_gold_default_hint, test_edge_tier_bronze_reflected, test_edge_tier_silver_reflected, test_edge_tier_unknown_falls_back_to_gold_hint, test_edge_style_reflected, test_edge_badge_reflected, test_edge_lora_injected, test_error_empty_badge, test_determinism]
|
||||
file_path: python/functions/ml/comfyui_build_achievement_badge_workflow.py
|
||||
---
|
||||
|
||||
Construye el dict (API format) del workflow de UNA insignia / medalla / logro 2D
|
||||
(achievement, recompensa, rango): un trofeo, una medalla con cinta, un escudo de logro,
|
||||
una estrella o un badge de rango que la UI de achievements pinta cuando el jugador
|
||||
desbloquea un hito, con TIER metalico (bronce / plata / oro / platino / diamante) que
|
||||
distingue el grado. Centrado, fondo limpio uniforme, recortable a alpha, estilo
|
||||
consistente entre insignias del mismo set. Compone `comfyui_build_txt2img_workflow` +
|
||||
`comfyui_inject_lora` (estilo opcional) + `Image Rembg` (fondo transparente si
|
||||
`transparent`). Hermano de `comfyui_build_item_icon_workflow` /
|
||||
`comfyui_build_skill_tree_node_workflow`. Pura, sin red ni I/O. class_types verificados
|
||||
contra `/object_info`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_achievement_badge_workflow import comfyui_build_achievement_badge_workflow
|
||||
|
||||
# Insignia de logro tier oro, medalla con cinta, fondo transparente (alpha).
|
||||
wf = comfyui_build_achievement_badge_workflow(
|
||||
"dragon slayer",
|
||||
tier="gold",
|
||||
style="game achievement badge, ornate",
|
||||
transparent=True,
|
||||
seed=12345,
|
||||
)
|
||||
# La familia de un mismo logro (los grados del panel): mismo badge/style/seed,
|
||||
# solo cambia tier. El motor intercambia el sprite segun el grado conseguido.
|
||||
# for t in ["bronze", "silver", "gold"]:
|
||||
# wf = comfyui_build_achievement_badge_workflow("dragon slayer", tier=t, seed=12345)
|
||||
# comfyui_submit_workflow(wf) # -> comfyui_wait_result -> comfyui_fetch_output_image
|
||||
# Set completo coherente: misma firma de style/checkpoint por insignia, varia badge.
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_achievement_badge_workflow` (imprime nodos + class_types del ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites las insignias del sistema de logros / recompensas de un juego: cada
|
||||
achievement desbloqueado se muestra como una medalla, trofeo o escudo con cinta y un
|
||||
grado metalico (bronce / plata / oro). Genera la **familia** `tier="bronze"` /
|
||||
`"silver"` / `"gold"` con el mismo `badge`/`style`/`seed` para tener las caras
|
||||
coherentes del mismo logro; el motor cambia el sprite segun el grado que el jugador
|
||||
haya conseguido. Pasa el MISMO `style` + `checkpoint` + (`lora`) a todas las insignias
|
||||
del set para que el panel de logros combine. `transparent` recorta la silueta de la
|
||||
medalla + cinta (alpha) lista para el motor.
|
||||
|
||||
Eligela frente a sus hermanos por el ROL del asset:
|
||||
- **item_icon** -> objeto de inventario (espada, pocion): ilustracion de un objeto
|
||||
suelto que el jugador usa/equipa, SIN tier ni cinta.
|
||||
- **status_effect_icon** -> simbolo de estado compacto que se superpone al retrato/barra
|
||||
del personaje (envenenado, aturdido), SIN marco; optimizado para 16-32 px.
|
||||
- **skill_tree_node** -> nodo ENMARCADO de la pantalla de talentos con estado
|
||||
unlocked/locked; vive en una rejilla de progresion.
|
||||
- **achievement_badge (esta)** -> la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con
|
||||
cinta + tier (bronce/plata/oro). Se muestra en el panel de logros.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El tier y la cinta son la firma**: lo que distingue este builder de
|
||||
item_icon/skill_tree_node es que el asset es una medalla/trofeo con `tier` metalico y
|
||||
cinta. El negativo por defecto NO rechaza "ribbon/medal/trophy" (son parte del asset),
|
||||
pero si rechaza escenas recargadas, multiples badges y fondos sucios. Si el modelo
|
||||
ignora el tier metalico, refuerza `style` con "{tier} medal, metallic sheen".
|
||||
- **La familia de tiers debe compartir parametros**: para que los grados del mismo logro
|
||||
coincidan en composicion, fija el mismo `badge`/`style`/`seed` y varia SOLO `tier`. La
|
||||
pista metalica del tier (`bronze` -> bronze/copper; `silver` -> chrome/grey; `gold` ->
|
||||
golden/yellow; `platinum`/`diamond` -> top tier) se anade automaticamente en el prompt.
|
||||
Un tier desconocido se inserta literal pero la pista cae a la de `gold`.
|
||||
- **Coherencia del set = mismos parametros**: si cambias `style`/`checkpoint`/`lora`
|
||||
entre insignias, el panel deja de combinar. Fija esos y varia solo `badge` (y `tier`).
|
||||
- **El recorte usa Rembg, NO luma-to-alpha**: una insignia es una pieza SOLIDA con
|
||||
silueta definida (la medalla perfila el borde, la cinta cuelga), rembg la recorta
|
||||
limpio. `comfyui_matting_luma_to_alpha` es para translucidos sobre negro (humo/fuego/
|
||||
runas brillantes) y aplanaria la medalla — no la uses para estas insignias.
|
||||
- **El texto/nombre lo pone el motor, no la imagen**: el negativo por defecto empuja a
|
||||
"no text/no letters/no numbers" para que la insignia quede limpia; el nombre del logro,
|
||||
la descripcion y la fecha los renderiza el juego sobre el badge.
|
||||
- **SDXL pide mas VRAM y resolucion**: con `checkpoint="IMG_juggernaut_xl_v11.safetensors"`
|
||||
sube `size` a 512; con IMG_dreamshaper_8 (SD1.5) deja 256 (holgado en 8GB lowvram).
|
||||
- `transparent=False` deja la insignia opaca sobre fondo plano: util si prefieres
|
||||
recortar fuera del workflow o el motor compone sobre un slot solido.
|
||||
- Es una funcion **pura**: solo arma el dict. La generacion real (GPU) la hacen
|
||||
`comfyui_submit_workflow` + `comfyui_wait_result` + `comfyui_fetch_output_image`.
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Construye el workflow ComfyUI de UNA insignia / medalla / logro (API format).
|
||||
|
||||
Insignia de logro de juego (achievement, recompensa, rango): un trofeo, una
|
||||
medalla con cinta, un escudo de logro, una estrella o un badge de rango que la UI
|
||||
de achievements/recompensas pinta cuando el jugador desbloquea un hito. Tiene
|
||||
TIER (bronce / plata / oro u otro grado) que distingue el nivel del logro,
|
||||
centrado, fondo limpio uniforme, recortable a alpha, estilo consistente entre
|
||||
insignias del mismo set. Es el builder hermano de comfyui_build_item_icon /
|
||||
comfyui_build_skill_tree_node: mismo patron (PURO, dict API format) que compone
|
||||
funciones existentes del registry, no reescribe el grafo.
|
||||
|
||||
DISTINTO de item_icon, status_effect_icon y skill_tree_node:
|
||||
- item_icon -> objeto de inventario (espada, pocion): ilustracion de un
|
||||
objeto suelto que el jugador usa/equipa, SIN tier, SIN cinta.
|
||||
- status_effect_icon -> simbolo de estado compacto que se superpone al retrato/barra
|
||||
del personaje (envenenado, aturdido); SIN marco, 16-32 px.
|
||||
- skill_tree_node -> nodo ENMARCADO de la pantalla de talentos con estado
|
||||
unlocked/locked; vive en una rejilla de progresion.
|
||||
- achievement_badge -> ESTO: la INSIGNIA DE LOGRO/RECOMPENSA = trofeo/medalla con
|
||||
cinta + tier (bronce/plata/oro). El tier metalico y la forma
|
||||
de medalla/trofeo son la firma del asset; se muestra en el
|
||||
panel de logros, no en inventario ni en arbol de talentos.
|
||||
|
||||
Por que importa el tier: un sistema de achievements muestra el MISMO logro en varios
|
||||
grados (bronce / plata / oro) segun el progreso o la dificultad alcanzada. Generar la
|
||||
familia (tier="bronze" / "silver" / "gold") con el mismo badge/style/seed da las
|
||||
caras coherentes del mismo logro; el motor intercambia el sprite segun el tier que el
|
||||
jugador haya conseguido.
|
||||
|
||||
Cableado:
|
||||
|
||||
CheckpointLoaderSimple -> [LoraLoader opcional de estilo] -> KSampler
|
||||
-> CLIPTextEncode (prompt scaffold de insignia de logro) ...
|
||||
-> VAEDecode -> [Image Rembg opcional] -> SaveImage
|
||||
|
||||
Compone:
|
||||
- comfyui_build_txt2img_workflow -> base txt2img cuadrada
|
||||
- comfyui_inject_lora -> LoRA de estilo opcional (consistencia del set)
|
||||
- 'Image Rembg (Remove Background)' (helper local) -> fondo transparente
|
||||
|
||||
Por que Rembg y NO comfyui_matting_luma_to_alpha: una insignia es una pieza SOLIDA
|
||||
con silueta definida (la medalla/trofeo perfila el borde, la cinta cuelga); rembg
|
||||
recorta limpio la silueta dejando alpha. La luma-to-alpha es para translucidos sobre
|
||||
negro (humo/fuego/runas brillantes) y aplanaria la medalla. Si el caller prefiere
|
||||
recortar fuera del workflow (transparent=False) deja la imagen opaca sobre fondo
|
||||
plano, recortable luego por el pipeline o el caller.
|
||||
|
||||
El mismo style + checkpoint + (lora) en todas las insignias del set hace que el panel
|
||||
de logros combine visualmente: es la clave de un set coherente, igual que en los
|
||||
iconos de inventario y los nodos del arbol de talentos.
|
||||
|
||||
class_types/inputs verificados contra /object_info del servidor (8GB lowvram):
|
||||
CheckpointLoaderSimple, CLIPTextEncode, EmptyLatentImage, KSampler, VAEDecode,
|
||||
SaveImage, LoraLoader, 'Image Rembg (Remove Background)' (transparency BOOLEAN).
|
||||
|
||||
Funcion pura: sin red, sin I/O. No muta dicts de entrada (copia profunda en el
|
||||
helper de rembg). Determinista para los mismos argumentos.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Negativo por defecto pensado para insignias de logro: una sola medalla/trofeo
|
||||
# centrado y recortable, sin escena ni personaje ni texto/marcas que ensucien la
|
||||
# silueta. NO rechaza "ribbon/medal/trophy" (son parte del asset), pero si empuja
|
||||
# contra escenas recargadas, multiples badges y fondos sucios.
|
||||
_BADGE_NEGATIVE = (
|
||||
"blurry, lowres, busy background, cluttered, multiple badges, multiple medals, "
|
||||
"detailed scene, landscape, full body character, person, face, text, letters, "
|
||||
"words, numbers, watermark, signature, photo, photorealistic, realistic, "
|
||||
"jpeg artifacts, cropped, out of frame, deformed"
|
||||
)
|
||||
|
||||
# Pistas visuales por tier: refuerzan en el prompt el aspecto metalico de la medalla
|
||||
# segun su grado. El nombre del tier tambien se inserta literal en el prompt para que
|
||||
# sea explicito. Un tier desconocido cae a la pista de "gold".
|
||||
_TIER_HINTS = {
|
||||
"bronze": "bronze metal, copper tones, dark brown sheen, lowest tier",
|
||||
"silver": "silver metal, polished chrome, cool grey sheen, mid tier",
|
||||
"gold": "gold metal, shiny golden, warm yellow sheen, highest tier",
|
||||
"platinum": "platinum metal, bright white-silver, prestige sheen, top tier",
|
||||
"diamond": "diamond crystal, brilliant facets, prismatic shine, elite tier",
|
||||
}
|
||||
|
||||
|
||||
def _inject_rembg(workflow: dict, model: str) -> dict:
|
||||
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
|
||||
|
||||
Mismo helper que usan comfyui_build_item_icon_workflow / comfyui_build_skill_tree_node_workflow:
|
||||
el nodo recorta la silueta de la insignia (medalla + cinta) dejando alpha. Repunta
|
||||
SaveImage.images a la salida del Rembg.
|
||||
"""
|
||||
wf = copy.deepcopy(workflow)
|
||||
vaedecode_id = next(
|
||||
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
|
||||
)
|
||||
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
|
||||
if vaedecode_id is None or save_id is None:
|
||||
raise ValueError(
|
||||
"comfyui_build_achievement_badge_workflow: no se encontro VAEDecode/SaveImage para Rembg"
|
||||
)
|
||||
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||
wf[rembg_id] = {
|
||||
"class_type": "Image Rembg (Remove Background)",
|
||||
"inputs": {
|
||||
"images": [vaedecode_id, 0],
|
||||
"transparency": True,
|
||||
"model": model,
|
||||
"post_processing": False,
|
||||
"only_mask": False,
|
||||
"alpha_matting": False,
|
||||
"alpha_matting_foreground_threshold": 240,
|
||||
"alpha_matting_background_threshold": 10,
|
||||
"alpha_matting_erode_size": 10,
|
||||
"background_color": "none",
|
||||
},
|
||||
}
|
||||
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
|
||||
return wf
|
||||
|
||||
|
||||
def comfyui_build_achievement_badge_workflow(
|
||||
badge: str,
|
||||
*,
|
||||
tier: str = "gold",
|
||||
style: str = "game achievement badge, ornate",
|
||||
checkpoint: str = "IMG_dreamshaper_8.safetensors",
|
||||
size: int = 256,
|
||||
transparent: bool = True,
|
||||
seed: int = 0,
|
||||
lora: str | None = None,
|
||||
lora_strength: float = 1.0,
|
||||
rembg_model: str = "u2net",
|
||||
negative: str | None = None,
|
||||
steps: int = 28,
|
||||
cfg: float = 7.0,
|
||||
sampler_name: str = "dpmpp_2m",
|
||||
scheduler: str = "karras",
|
||||
filename_prefix: str = "achievement_badge",
|
||||
) -> dict:
|
||||
"""Construye el dict (API format) del workflow de una insignia / medalla / logro.
|
||||
|
||||
Args:
|
||||
badge: nombre / tema del logro que representa la insignia (ej. "dragon slayer",
|
||||
"first blood", "100 wins", "explorer", "marathon runner", "boss killer").
|
||||
Se inserta en un prompt scaffold de insignia. No puede estar vacio.
|
||||
tier: grado del logro: "bronze", "silver", "gold" (por defecto), "platinum" o
|
||||
"diamond". Define el aspecto metalico de la medalla. Genera la familia con
|
||||
el mismo badge/style/seed variando solo el tier para tener las caras
|
||||
coherentes del mismo logro. Cualquier otro valor se inserta literal (el
|
||||
tier hint cae al de "gold"). keyword-only.
|
||||
style: descriptor de estilo que mantiene consistentes las insignias de un set
|
||||
(ej. "game achievement badge, ornate", "flat minimal medal", "pixel art
|
||||
trophy"). Pasa el MISMO style + checkpoint + (lora) a todas las insignias
|
||||
del set para coherencia visual. keyword-only.
|
||||
checkpoint: checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5,
|
||||
holgado en 8GB lowvram) por defecto; 'IMG_juggernaut_xl_v11.safetensors' para
|
||||
SDXL (mas VRAM, subir size). keyword-only.
|
||||
size: lado del cuadrado en px (width = height = size). 256 por defecto: las
|
||||
insignias de logro se muestran a tamano reducido en el panel. keyword-only.
|
||||
transparent: si True inyecta Rembg y el PNG sale con alpha (fondo recortado,
|
||||
la silueta de la medalla + cinta). Si False deja la insignia opaca sobre
|
||||
fondo plano, recortable luego por el caller/pipeline. keyword-only.
|
||||
seed: semilla del KSampler. Fija el mismo seed en la familia de tiers del mismo
|
||||
logro para que coincidan en composicion. keyword-only.
|
||||
lora: LoRA de estilo opcional en models/loras (ej.
|
||||
'SD15_detail_tweaker.safetensors'). None = sin LoRA. keyword-only.
|
||||
lora_strength: fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0].
|
||||
keyword-only.
|
||||
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo se
|
||||
usa si transparent=True. keyword-only.
|
||||
negative: prompt negativo. None usa el negativo por defecto pensado para
|
||||
insignias (una medalla limpia, fondo limpio, sin escena/personaje/texto).
|
||||
keyword-only.
|
||||
steps, cfg, sampler_name, scheduler, filename_prefix: parametros de
|
||||
generacion. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow: txt2img base cuadrada
|
||||
con prompt scaffold de insignia ('{badge} achievement badge, {tier} tier
|
||||
({tier hint}), {style}, medal with ribbon, centered, plain background, game UI
|
||||
reward, ...') + LoRA de estilo opcional + Rembg (si transparent). Es UNA
|
||||
insignia; un set de logros -> llamar por badge (y por tier) con el mismo
|
||||
style/checkpoint/lora y montar el panel en el motor.
|
||||
|
||||
Raises:
|
||||
ValueError: si badge esta vacio, o si la base no tiene VAEDecode/SaveImage
|
||||
donde inyectar el Rembg (propagado por el helper).
|
||||
"""
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
if not badge or not badge.strip():
|
||||
raise ValueError(
|
||||
"comfyui_build_achievement_badge_workflow: 'badge' no puede estar vacio"
|
||||
)
|
||||
|
||||
lora_strength = max(0.0, min(2.0, float(lora_strength)))
|
||||
neg = _BADGE_NEGATIVE if negative is None else negative
|
||||
|
||||
badge_s = badge.strip()
|
||||
tier_s = (tier or "gold").strip()
|
||||
style_s = (style or "game achievement badge, ornate").strip()
|
||||
# Pista de aspecto metalico segun tier; tier desconocido -> aspecto de gold.
|
||||
tier_hint = _TIER_HINTS.get(tier_s.lower(), _TIER_HINTS["gold"])
|
||||
|
||||
# Prompt scaffold de insignia de logro: medalla/trofeo con cinta, tier metalico,
|
||||
# centrado, fondo plano, recortable. badge/tier/style quedan reflejados literalmente.
|
||||
positive = (
|
||||
f"{badge_s} achievement badge, {tier_s} tier ({tier_hint}), {style_s}, "
|
||||
"medal with ribbon, centered, plain background, game UI reward, "
|
||||
"trophy emblem, clean, high detail"
|
||||
)
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
checkpoint,
|
||||
positive,
|
||||
neg,
|
||||
steps=steps,
|
||||
cfg=cfg,
|
||||
width=size,
|
||||
height=size,
|
||||
seed=seed,
|
||||
sampler_name=sampler_name,
|
||||
scheduler=scheduler,
|
||||
filename_prefix=filename_prefix,
|
||||
)
|
||||
|
||||
if lora:
|
||||
from ml.comfyui_inject_lora import comfyui_inject_lora
|
||||
|
||||
wf = comfyui_inject_lora(
|
||||
wf, lora, strength_model=lora_strength, strength_clip=lora_strength
|
||||
)
|
||||
|
||||
if transparent:
|
||||
wf = _inject_rembg(wf, rembg_model)
|
||||
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_achievement_badge_workflow(
|
||||
"dragon slayer",
|
||||
tier="gold",
|
||||
style="game achievement badge, ornate",
|
||||
transparent=True,
|
||||
seed=42,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"nodes": list(wf),
|
||||
"classes": sorted({n["class_type"] for n in wf.values()}),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests offline (sin red, sin GPU) de comfyui_build_achievement_badge_workflow.
|
||||
|
||||
Verifican que el dict en API format se construye correctamente: clases presentes,
|
||||
cableado del Rembg, prompt scaffold de insignia de logro, y reflejo de los argumentos
|
||||
(badge, tier, style, size, transparent, lora). No tocan el servidor ComfyUI.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from ml.comfyui_build_achievement_badge_workflow import ( # noqa: E402
|
||||
comfyui_build_achievement_badge_workflow,
|
||||
)
|
||||
|
||||
|
||||
def _classes(wf):
|
||||
return {n["class_type"] for n in wf.values()}
|
||||
|
||||
|
||||
def _positive_prompt(wf):
|
||||
"""Texto positivo: el CLIPTextEncode al que apunta KSampler.positive."""
|
||||
ks = next(n for n in wf.values() if n["class_type"] == "KSampler")
|
||||
pos_id = ks["inputs"]["positive"][0]
|
||||
return wf[pos_id]["inputs"]["text"]
|
||||
|
||||
|
||||
def test_golden_transparent():
|
||||
"""Caso feliz: insignia transparente -> Rembg cableado, prompt de logro, clases base."""
|
||||
wf = comfyui_build_achievement_badge_workflow(
|
||||
"dragon slayer", tier="gold", transparent=True, seed=42
|
||||
)
|
||||
cls = _classes(wf)
|
||||
for expected in {
|
||||
"CheckpointLoaderSimple",
|
||||
"KSampler",
|
||||
"VAEDecode",
|
||||
"SaveImage",
|
||||
"Image Rembg (Remove Background)",
|
||||
}:
|
||||
assert expected in cls, f"falta clase {expected}"
|
||||
|
||||
prompt = _positive_prompt(wf)
|
||||
assert "dragon slayer" in prompt
|
||||
assert "achievement badge" in prompt
|
||||
assert "gold tier" in prompt
|
||||
assert "medal with ribbon" in prompt
|
||||
assert "centered" in prompt
|
||||
assert "game UI reward" in prompt
|
||||
|
||||
# SaveImage debe tomar la imagen del Rembg, no del VAEDecode.
|
||||
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||
rembg_id = next(
|
||||
nid for nid, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)"
|
||||
)
|
||||
assert save["inputs"]["images"][0] == rembg_id
|
||||
rembg = wf[rembg_id]
|
||||
assert rembg["inputs"]["transparency"] is True
|
||||
|
||||
|
||||
def test_edge_transparent_false_no_rembg():
|
||||
"""transparent=False -> sin nodo Rembg; SaveImage cuelga del VAEDecode."""
|
||||
wf = comfyui_build_achievement_badge_workflow("first blood", transparent=False)
|
||||
assert "Image Rembg (Remove Background)" not in _classes(wf)
|
||||
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||
vae_id = next(nid for nid, n in wf.items() if n["class_type"] == "VAEDecode")
|
||||
assert save["inputs"]["images"][0] == vae_id
|
||||
|
||||
|
||||
def test_edge_size_reflected_square():
|
||||
"""size se refleja como width == height (cuadrado). Default 256 (insignia compacta)."""
|
||||
wf = comfyui_build_achievement_badge_workflow("explorer", size=128)
|
||||
latent = next(n for n in wf.values() if n["class_type"] == "EmptyLatentImage")
|
||||
assert latent["inputs"]["width"] == 128
|
||||
assert latent["inputs"]["height"] == 128
|
||||
|
||||
wf_default = comfyui_build_achievement_badge_workflow("explorer")
|
||||
latent_d = next(n for n in wf_default.values() if n["class_type"] == "EmptyLatentImage")
|
||||
assert latent_d["inputs"]["width"] == 256
|
||||
assert latent_d["inputs"]["height"] == 256
|
||||
|
||||
|
||||
def test_edge_tier_gold_default_hint():
|
||||
"""tier='gold' (default) se refleja literal y arrastra la pista metalica de oro."""
|
||||
wf = comfyui_build_achievement_badge_workflow("boss killer")
|
||||
prompt = _positive_prompt(wf)
|
||||
assert "gold tier" in prompt
|
||||
assert "gold metal" in prompt
|
||||
|
||||
|
||||
def test_edge_tier_bronze_reflected():
|
||||
"""tier='bronze' se refleja literal y arrastra la pista metalica de bronce."""
|
||||
wf = comfyui_build_achievement_badge_workflow("rookie", tier="bronze")
|
||||
prompt = _positive_prompt(wf)
|
||||
assert "bronze tier" in prompt
|
||||
assert "bronze metal" in prompt
|
||||
|
||||
|
||||
def test_edge_tier_silver_reflected():
|
||||
"""tier='silver' se refleja literal y arrastra la pista metalica de plata."""
|
||||
wf = comfyui_build_achievement_badge_workflow("veteran", tier="silver")
|
||||
prompt = _positive_prompt(wf)
|
||||
assert "silver tier" in prompt
|
||||
assert "silver metal" in prompt
|
||||
|
||||
|
||||
def test_edge_tier_unknown_falls_back_to_gold_hint():
|
||||
"""tier desconocido se inserta literal pero la pista cae a la de gold."""
|
||||
wf = comfyui_build_achievement_badge_workflow("mythic", tier="legendary")
|
||||
prompt = _positive_prompt(wf)
|
||||
assert "legendary tier" in prompt
|
||||
assert "gold metal" in prompt # hint fallback
|
||||
|
||||
|
||||
def test_edge_style_reflected():
|
||||
"""style se inserta en el prompt positivo."""
|
||||
wf = comfyui_build_achievement_badge_workflow(
|
||||
"speedrun", style="pixel art trophy"
|
||||
)
|
||||
assert "pixel art trophy" in _positive_prompt(wf)
|
||||
|
||||
|
||||
def test_edge_badge_reflected():
|
||||
"""badge se inserta literal en el prompt positivo."""
|
||||
wf = comfyui_build_achievement_badge_workflow("marathon runner")
|
||||
assert "marathon runner" in _positive_prompt(wf)
|
||||
|
||||
|
||||
def test_edge_lora_injected():
|
||||
"""lora -> LoraLoader presente con la fuerza dada."""
|
||||
wf = comfyui_build_achievement_badge_workflow(
|
||||
"collector", lora="SD15_detail_tweaker.safetensors", lora_strength=0.8
|
||||
)
|
||||
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
|
||||
assert len(loras) == 1
|
||||
assert loras[0]["inputs"]["lora_name"] == "SD15_detail_tweaker.safetensors"
|
||||
assert loras[0]["inputs"]["strength_model"] == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_error_empty_badge():
|
||||
"""badge vacio -> ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
comfyui_build_achievement_badge_workflow(" ")
|
||||
|
||||
|
||||
def test_determinism():
|
||||
"""Mismos argumentos -> mismo dict (funcion pura)."""
|
||||
a = comfyui_build_achievement_badge_workflow("undefeated", seed=7)
|
||||
b = comfyui_build_achievement_badge_workflow("undefeated", seed=7)
|
||||
assert a == b
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: comfyui_build_asset_variant_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
purity: pure
|
||||
version: 1.0.0
|
||||
signature: "def comfyui_build_asset_variant_workflow(input_image: str, variant: str, *, checkpoint: str = \"IMG_dreamshaper_8.safetensors\", denoise: float = 0.5, style: str = \"game asset\", size: int | None = 512, seed: int = 0, lora: str | None = None, lora_strength: float = 1.0, upscale_method: str = \"lanczos\", crop: str = \"disabled\", negative: str | None = None, steps: int = 28, cfg: float = 7.0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", filename_prefix: str = \"asset_variant\") -> dict"
|
||||
description: "Construye el dict (API format) del workflow de una VARIANTE img2img de un asset 2D ya generado: parte de una IMAGEN existente (un sprite de enemigo, un icono...) y produce una version coherente que cambia material/paleta/tier/estado (ice element, fire element, battle-damaged, golden tier 2, corrupted) manteniendo la composicion, la pose y la silueta del original. A diferencia de los builders gamedev hermanos (enemy_creature, item_icon...), que parten de TEXTO (txt2img desde ruido), este parte de una imagen via img2img con denoise MEDIO (~0.45-0.6): el KSampler arranca del latente de la imagen base, no de ruido. Normaliza el tamano con un ImageScale opcional (size) o preserva las dimensiones del original (size=None). Compone comfyui_build_img2img_workflow + comfyui_inject_lora (estilo opcional). Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||
tags: [comfyui, ml, gamedev-2d, img2img, variant, asset-transform, stable-diffusion, workflow]
|
||||
uses_functions: [comfyui_build_img2img_workflow_py_ml, comfyui_inject_lora_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
params:
|
||||
- name: input_image
|
||||
desc: "Nombre del archivo de la imagen base dentro de la carpeta input/ del servidor ComfyUI (un asset YA generado). Lo carga el nodo LoadImage. Subelo antes con POST /upload/image o copialo a ~/ComfyUI/input/. No puede estar vacio."
|
||||
- name: variant
|
||||
desc: "Descripcion de la variante a producir (ej. 'ice element, frozen', 'fire element, molten', 'battle-damaged, cracked', 'golden tier 2', 'corrupted shadow'). Reescribe material/paleta/estado del asset manteniendo su composicion. No describe el sujeto desde cero: transforma el que ya existe en input_image. No puede estar vacio."
|
||||
- name: checkpoint
|
||||
desc: "Checkpoint del servidor. 'IMG_dreamshaper_8.safetensors' (SD1.5, holgado en 8GB lowvram) por defecto. keyword-only."
|
||||
- name: denoise
|
||||
desc: "Fuerza de denoising del KSampler (cuanto se aparta del original). ~0.3 apenas cambia; 0.45-0.6 (recomendado) cambia material/paleta conservando silueta/pose; ~0.8 se aleja y empieza a ser casi txt2img. Se clampa a [0.0, 1.0]. keyword-only."
|
||||
- name: style
|
||||
desc: "Descriptor de estilo que mantiene coherentes las variantes de un set (ej. 'game asset', 'dark fantasy creature', 'pixel art'). Mismo style + checkpoint + (lora) en todas las variantes del mismo asset. keyword-only."
|
||||
- name: size
|
||||
desc: "Lado en px al que se NORMALIZA la imagen base antes de encodearla (inserta un ImageScale a size x size). None = no escala; la variante hereda las dimensiones EXACTAS del original (preserva proporcion sin deformar). 512 por defecto (SD1.5). keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. keyword-only."
|
||||
- name: lora
|
||||
desc: "LoRA de estilo opcional en models/loras (ej. 'SD15_dark_fantasy.safetensors'). None = sin LoRA. keyword-only."
|
||||
- name: lora_strength
|
||||
desc: "Fuerza del LoRA sobre model y clip. Se clampa a [0.0, 2.0]. keyword-only."
|
||||
- name: upscale_method
|
||||
desc: "Metodo del ImageScale ('lanczos', 'bilinear', 'bicubic', 'area', 'nearest-exact'). Solo se usa si size no es None. keyword-only."
|
||||
- name: crop
|
||||
desc: "Modo de recorte del ImageScale ('disabled' conserva todo el contenido, 'center' recorta al centro para encajar el ratio). Solo si size no es None. keyword-only."
|
||||
- name: negative
|
||||
desc: "Prompt negativo. None usa el negativo por defecto pensado para variantes (conservar pose/composicion, una figura, fondo limpio). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (ej. 'dpmpp_2m', 'euler'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (ej. 'karras', 'normal'). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
||||
tested: true
|
||||
tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py"
|
||||
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
||||
---
|
||||
|
||||
Construye el dict (API format) del workflow de una **variante de un asset 2D que ya
|
||||
existe** (img2img). Builder gamedev hermano de `comfyui_build_enemy_creature_workflow`
|
||||
e `comfyui_build_item_icon_workflow`, pero con un eje distinto: en vez de generar un
|
||||
TIPO de asset desde texto, **transforma** una imagen concreta (un sprite ya generado)
|
||||
en una variante coherente — la version "de hielo", "de fuego", "dañada" o "tier 2
|
||||
dorada" — conservando silueta, pose y composición del original.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_asset_variant_workflow import comfyui_build_asset_variant_workflow
|
||||
|
||||
# Variante "de hielo" de un sprite de goblin ya generado (subido al input/ del server)
|
||||
wf = comfyui_build_asset_variant_workflow(
|
||||
"enemy_creature_00001_.png", # asset existente en el input/ de ComfyUI
|
||||
"ice element, frozen", # la variante a producir
|
||||
style="dark fantasy creature, game asset",
|
||||
denoise=0.5, # medio: cambia material/paleta, conserva silueta
|
||||
seed=7,
|
||||
)
|
||||
# wf parte de una imagen (img2img), NO de ruido:
|
||||
# "VAEEncode" in {n["class_type"] for n in wf.values()} # True
|
||||
# "EmptyLatentImage" not in {n["class_type"] for n in wf.values()} # True (no es txt2img)
|
||||
# wf["10"]["inputs"]["image"] == "enemy_creature_00001_.png"
|
||||
# wf["3"]["inputs"]["denoise"] == 0.5
|
||||
# "ice element, frozen" in wf["6"]["inputs"]["text"]
|
||||
```
|
||||
|
||||
El bloque 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 `comfyui_build_img2img_workflow`.
|
||||
Usa el import de arriba o un heredoc.
|
||||
|
||||
Set de variantes del MISMO asset (mismo `input_image`/`style`/`seed`, distinto `variant`):
|
||||
|
||||
```python
|
||||
for v in ["ice element, frozen", "fire element, molten", "battle-damaged, cracked", "golden tier 2"]:
|
||||
wf = comfyui_build_asset_variant_workflow("enemy_creature_00001_.png", v,
|
||||
style="dark fantasy creature, game asset",
|
||||
denoise=0.5, seed=7)
|
||||
# enviar con comfyui_submit_workflow -> familia coherente de variantes
|
||||
```
|
||||
|
||||
Para enviar a la GPU: subir la base con `POST /upload/image`, luego
|
||||
`comfyui_submit_workflow(wf)` + `comfyui_wait_result(prompt_id)` +
|
||||
`comfyui_fetch_output_image(filename)`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes un asset 2D generado y quieres **derivar variantes coherentes** de
|
||||
él (elemento/material/tier/estado) sin redibujar desde cero: el sprite de hielo del
|
||||
mismo enemigo, la armadura dorada del mismo personaje, la versión dañada del mismo
|
||||
prop. Es img2img con denoise medio que conserva la composición original. Para generar
|
||||
un asset NUEVO desde texto usa los builders txt2img hermanos
|
||||
(`comfyui_build_enemy_creature_workflow`, `comfyui_build_item_icon_workflow`...); para
|
||||
ampliar/refinar resolución usa `comfyui_build_upscale_workflow`; para img2img genérico
|
||||
sin scaffolding de variante usa `comfyui_build_img2img_workflow` directo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es **img2img**, no txt2img: SIEMPRE parte de una imagen (`input_image`), no de ruido
|
||||
en blanco. Esa imagen debe existir en la carpeta `input/` del servidor ComfyUI
|
||||
(subir con `POST /upload/image` o copiar a `~/ComfyUI/input/`). Es pura: NO valida
|
||||
que exista; si no está, ComfyUI rechaza el workflow con HTTP 400 al enviarlo. Valida
|
||||
antes con `comfyui_validate_workflow`.
|
||||
- `denoise` es la palanca clave: cerca de 0.0 apenas cambia (variante invisible);
|
||||
0.45-0.6 es el rango útil (cambia material/paleta manteniendo silueta); cerca de 0.8
|
||||
se aleja del original y deriva la pose/composición (deja de ser variante coherente y
|
||||
se acerca a un txt2img). Default 0.5.
|
||||
- `size` reescala la imagen base a `size x size` con un ImageScale ANTES de encodear.
|
||||
Con `size=512` y un asset cuadrado 512 es no-op de tamaño; con un asset NO cuadrado y
|
||||
`crop="disabled"` el ImageScale fuerza el ratio cuadrado y puede deformar — pasa
|
||||
`size=None` para preservar las dimensiones/proporción exactas del original, o
|
||||
`crop="center"` para recortar al centro en vez de deformar.
|
||||
- El prompt refuerza "same composition, same pose, same silhouette" además del denoise
|
||||
medio; aun así, denoise alto o un `variant` que implique cambio de forma (ej. "giant
|
||||
version") puede alterar la silueta. Para variantes solo de paleta/material, mantén
|
||||
denoise ≤0.55.
|
||||
- Asume checkpoint con VAE embebido (VAEEncode/VAEDecode usan el VAE del checkpoint).
|
||||
Para un VAE externo hay que reconectar esas entradas a mano.
|
||||
- 8GB lowvram: SD1.5 a 512² va holgado. Si OOM, baja `size` (384) o `denoise`; NO subas
|
||||
a SDXL en 8GB para esto.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user