Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 | |||
| 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 | |||
| fb76b53c17 | |||
| 8e16202935 | |||
| e4a36f1133 | |||
| 295f90afaf | |||
| f85c1a322a | |||
| 32c7336bf6 | |||
| c1071a82b3 |
@@ -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)
|
||||
|
||||
@@ -168,6 +192,13 @@ políticas por clasificación, verificador, auto-kill, nudge, splitter, cadencia
|
||||
no el número de agentes vivos — el hook te empuja un bloque `FLEET-STATE` cada turno; tú drenas con
|
||||
`./fn run drain_fleet_events` y actúas por clasificación.
|
||||
|
||||
**Vía preferida — tools MCP `fleet_*`:** si la sesión tiene el MCP `orchestrator` conectado (lo
|
||||
normal: está en `.mcp.json`), usa sus 6 tools — `mcp__orchestrator__fleet_list` / `fleet_drain` /
|
||||
`fleet_classify` / `fleet_set_dod` / `fleet_kill` / `fleet_spawn` — en lugar de los `./fn run`
|
||||
equivalentes: permisos pre-aprobados y salida estructurada, y `fleet_list` expone `role`/`dod_*`
|
||||
directamente. El `./fn run` (y el binario `fleetview` para el listado) es el fallback CLI. Mapa
|
||||
completo op→tool en `.claude/rules/orchestration.md`.
|
||||
|
||||
### 6. Parar un ejecutor — NUNCA `pkill`/`killall claude` (canónica)
|
||||
|
||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||
@@ -197,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`).
|
||||
@@ -261,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,15 +27,18 @@ 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)
|
||||
```
|
||||
|
||||
Nota: **NO** uses `./fn run list_claude_fleet` — `list_claude_fleet_go_infra` es una función Go con
|
||||
tests, así que `fn run` la despacha como `go test` (corre la suite, no imprime la flota). La vía
|
||||
ejecutable es el binario `apps/fleetview/fleetview` (el atajo `/fleet` del humano envuelve este mismo
|
||||
CLI). Gotcha: el JSON de `fleetview list` **no** incluye todavía `role`/`dod_contract`/`dod_status`;
|
||||
para esos campos lee el sidecar `~/.claude/goals/<session_id>.json` (ver abajo).
|
||||
CLI). El JSON de `fleetview list` **ya incluye** `role`/`dod_contract`/`dod_status` (además de
|
||||
`tmux_window`): el binario los serializa directamente (`""` cuando el `goal.json` no los declara,
|
||||
ver `apps/fleetview/cli.go`). El tool MCP `fleet_list` (ver abajo) además rellena los que el binario
|
||||
deje vacíos leyéndolos del sidecar `~/.claude/goals/<session_id>.json`, así que con el MCP nunca te
|
||||
faltan. Ya no hace falta leer el sidecar a mano salvo que uses el binario crudo y el campo venga vacío.
|
||||
|
||||
**Tiempo — usa el de ACTIVIDAD, no el del proceso.** Para "cuánto lleva cada agente" usa la columna
|
||||
`AGE` de `fleetview list` (o `age`/`idle_seconds` en `--json`): es el tiempo desde su última
|
||||
@@ -43,6 +46,39 @@ actividad (proxy de cuánto lleva sin avanzar / en su estado), lo útil para det
|
||||
`etime` de `list_claude_agents` es la **vida del proceso** (cuánto lleva la terminal abierta, p.ej.
|
||||
8h) — NO es el tiempo de la tarea; nunca lo reportes como progreso.
|
||||
|
||||
### Vía preferida: tools MCP `fleet_*` (`orchestrator_mcp`)
|
||||
|
||||
El MCP `orchestrator` (registrado en `.mcp.json` como `orchestrator`, binario
|
||||
`apps/orchestrator_mcp/orchestrator_mcp`) expone la maquinaria de la flota como **6 tools** que
|
||||
envuelven las mismas funciones del registry. **En una sesión con `orchestrator_mcp` conectado,
|
||||
prefiere los tools `mcp__orchestrator__fleet_*` sobre `./fn run`**: tienen permisos pre-aprobados,
|
||||
devuelven salida estructurada y se registran en la telemetría como cualquier MCP (regla
|
||||
`registry_calls.md`). El `./fn run` (o el binario `fleetview` para el listado) sigue siendo el
|
||||
**fallback CLI** cuando el MCP no está conectado. Mapa de cada operación de la flota a su tool:
|
||||
|
||||
| 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**, **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` |
|
||||
| Cerrar dirigido UN ejecutor (auto-kill: SIGTERM + kill-window, con guards) | `mcp__orchestrator__fleet_kill` (`dry_run` para ver el plan) | `./fn run kill_fleet_agent` |
|
||||
| Lanzar un ejecutor como window de la flota tmux (con `parent` para el push) | `mcp__orchestrator__fleet_spawn` | `./fn run spawn_fleet_agent` |
|
||||
|
||||
Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directamente (y rellena los
|
||||
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 |
|
||||
@@ -97,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
|
||||
@@ -134,10 +185,14 @@ produce `classify_fleet_termination` (pura) desde su estado (status + phase + do
|
||||
dod_status + segundos ociosos).
|
||||
|
||||
**No te vigiles a ti mismo.** Al procesar la cola, **ignora** los eventos de tu propia sesión y de
|
||||
cualquier agente con `role=orchestrator`. Como `fleetview list --json` no expone `role`, resuélvelo
|
||||
leyendo el sidecar del goal de cada `session_id`:
|
||||
cualquier agente con `role=orchestrator`. El `role` ya viene en cada fila de `fleet_list` (y de
|
||||
`fleetview list --json`), así que filtras directamente por ese campo. Solo si usas el binario crudo y
|
||||
la fila trae `role` vacío, cae al sidecar del goal de cada `session_id`:
|
||||
|
||||
```bash
|
||||
# Preferido: filtrar por el role que ya trae fleet_list / fleetview list --json.
|
||||
apps/fleetview/fleetview list --json | jq -r '.[] | select((.role // "executor") != "orchestrator") | .session_id'
|
||||
# Fallback solo si el binario dejó role vacío en alguna fila:
|
||||
jq -r '.role // "executor"' ~/.claude/goals/<session_id>.json # "orchestrator" => ignóralo
|
||||
```
|
||||
|
||||
@@ -208,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.
|
||||
@@ -271,16 +332,19 @@ en lote.
|
||||
| `drain_fleet_events_py_infra` | Consumir la cola de transiciones del watcher (`~/.claude/fleet/events.jsonl`), agrupada por clasificación + urgentes |
|
||||
| `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` + `tmux_window` (alimenta `/fleet` y el watcher). **Invócala por el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI aún no expone `role`/`dod_contract`/`dod_status`; léelos de `~/.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 |
|
||||
| `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` |
|
||||
| `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": [
|
||||
@@ -56,6 +57,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fleet_state_inject.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,9 +4,21 @@
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
},
|
||||
"orchestrator": {
|
||||
"command": "./apps/orchestrator_mcp/orchestrator_mcp",
|
||||
"args": []
|
||||
},
|
||||
"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,99 @@
|
||||
---
|
||||
name: check_service_health_via_ssh
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "check_service_health_via_ssh(ssh_host: string, local_url: string, [--token-from-env <remote_env_path> <ENV_VAR>], [--token <literal>], [--expect-status <code>], [--connect-timeout <s>], [--curl-timeout <s>]) -> json"
|
||||
description: "Comprueba la salud de un service HTTP que solo escucha en loopback (127.0.0.1) de un host remoto, entrando por SSH y haciendo curl con bearer token opcional. El token se resuelve dentro del host remoto (leyendo una variable de un .env remoto via grep, o pasado literal) y NUNCA se imprime ni se hardcodea. Emite JSON con http_code y healthy. Reemplaza el patron inline 'ssh host -> grep token .env -> curl -H Authorization: Bearer' repetido en monitorizacion."
|
||||
tags: [ssh, systemd, health, curl, remote, service, bearer, loopback, monitoring, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: ssh_host
|
||||
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: om, organic-machine). Resuelve user/puerto/identityfile del config."
|
||||
- name: local_url
|
||||
desc: "URL del endpoint que el service expone en loopback del host remoto (ej: http://127.0.0.1:8487/agent). No es accesible desde fuera del host."
|
||||
- name: --token-from-env
|
||||
desc: "dos valores: <remote_env_path> <ENV_VAR>. Lee el bearer del .env remoto con grep '^ENV_VAR=' (ej: /home/ubuntu/app/.env AGENTS_API_KEY). El token se resuelve dentro del host, no viaja en argv local."
|
||||
- name: --token
|
||||
desc: "bearer literal (alternativa a --token-from-env). Util para tokens ya en variables de entorno locales; preferir --token-from-env para secretos en disco remoto."
|
||||
- name: --expect-status
|
||||
desc: "codigo HTTP exacto que marca healthy (ej: 200). Si se omite, cualquier 2xx cuenta como healthy."
|
||||
- name: --connect-timeout
|
||||
desc: "timeout de conexion SSH en segundos (default 5)."
|
||||
- name: --curl-timeout
|
||||
desc: "timeout maximo del curl remoto en segundos (default 10)."
|
||||
output: "JSON a stdout: {\"status\":\"ok|error\",\"host\":\"...\",\"url\":\"...\",\"http_code\":NNN,\"healthy\":true|false}. status=error si el SSH fallo sin obtener codigo. healthy=true si http_code coincide con expect-status (o es 2xx por defecto). Exit 0 si healthy, 1 si no, 2 en error de uso."
|
||||
tested: true
|
||||
tests: ["service healthy con token desde env remoto", "service no healthy con http_code 503", "salida JSON nunca filtra el token", "sin token 2xx por defecto es healthy", "falta argumento obligatorio devuelve error de uso", "falta argumento sale con codigo distinto de 0"]
|
||||
test_file_path: "bash/functions/infra/check_service_health_via_ssh_test.sh"
|
||||
file_path: "bash/functions/infra/check_service_health_via_ssh.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/check_service_health_via_ssh.sh
|
||||
|
||||
# 1) Service en loopback del host 'om' con bearer leido de un .env remoto.
|
||||
# Reemplaza el patron inline de monitorizacion del agents_and_robots.
|
||||
result=$(check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
|
||||
--expect-status 200)
|
||||
echo "$result"
|
||||
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agent","http_code":200,"healthy":true}
|
||||
|
||||
# 2) Sin token (endpoint publico del host pero solo accesible por loopback).
|
||||
check_service_health_via_ssh organic-machine "http://127.0.0.1:8080/healthz"
|
||||
# {"status":"ok","host":"organic-machine","url":"http://127.0.0.1:8080/healthz","http_code":200,"healthy":true}
|
||||
|
||||
# 3) Uso como gate en un script de monitorizacion (exit code).
|
||||
if check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY >/dev/null; then
|
||||
echo "service vivo"
|
||||
else
|
||||
echo "service caido — alertar"
|
||||
fi
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites comprobar si un service HTTP de un host remoto esta sano y ese
|
||||
service **solo escucha en loopback** (127.0.0.1) del host, por lo que no puedes
|
||||
curl-earlo directamente desde tu maquina. Tipico de APIs internas detras de un reverse
|
||||
proxy, daemons con bearer auth, o services systemd que exponen un `/health` privado.
|
||||
Antes de reiniciar un service, en un cron de monitorizacion, o como `e2e_check` de un
|
||||
deploy.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere **SSH por key auth** al host (usa `-o BatchMode=yes`): si el host pide
|
||||
password, falla en vez de colgarse. El alias debe estar en `~/.ssh/config`.
|
||||
- El service objetivo **debe escuchar en loopback del host remoto** — la URL se
|
||||
resuelve *dentro* del host. `http://127.0.0.1:PORT` apunta al host remoto, no a tu PC.
|
||||
- **No requiere sudo**: solo lee un `.env` (grep) y hace curl como el usuario SSH.
|
||||
El usuario SSH debe tener permiso de lectura sobre el `.env` remoto.
|
||||
- El **token nunca se imprime ni se hardcodea**: con `--token-from-env` se resuelve
|
||||
dentro del host y solo se usa en el header `Authorization`. Con `--token <literal>`
|
||||
el secreto queda en el argv del comando ssh local — preferir `--token-from-env`
|
||||
para secretos persistidos en disco.
|
||||
- `grep` del `.env` toma la **primera** linea que matchea `^<ENV_VAR>=` y recorta
|
||||
comillas/espacios. Si la var aparece varias veces o usa interpolacion, revisa el match.
|
||||
- `curl -sf` no sigue redirects: un 3xx cuenta como no-2xx (healthy=false salvo
|
||||
`--expect-status` explicito).
|
||||
- Requiere `curl` instalado en el **host remoto** (no en el local).
|
||||
- El JSON de salida se emite siempre (incluso en fallo); el caller decide por el
|
||||
`exit code` (0 healthy, 1 no healthy, 2 error de uso) o por el campo `healthy`.
|
||||
|
||||
## Notas
|
||||
|
||||
- Testeable sin red: el runner SSH es inyectable via `CHECK_HEALTH_SSH_BIN` (un stub
|
||||
que emite el `http_code` deseado), por eso los tests no abren conexiones reales.
|
||||
- El snippet remoto normaliza la salida de curl a un unico `http_code` aunque
|
||||
`curl -sf` devuelva error (emite `<curl_rc>:<http_code>` y la funcion extrae el codigo).
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# check_service_health_via_ssh — Comprueba la salud de un service HTTP que solo
|
||||
# escucha en loopback de un host remoto, entrando por SSH y haciendo curl con
|
||||
# bearer token opcional (leido de un .env remoto o pasado literal).
|
||||
set -euo pipefail
|
||||
|
||||
check_service_health_via_ssh() {
|
||||
local ssh_host="" local_url=""
|
||||
local remote_env_path="" env_var=""
|
||||
local token_literal=""
|
||||
local expect_status="" # vacio = aceptar cualquier 2xx
|
||||
local connect_timeout=5
|
||||
local curl_timeout=10
|
||||
|
||||
# --- parseo de args (posicionales + flags) ---
|
||||
local positional=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--token-from-env)
|
||||
remote_env_path="${2:-}"
|
||||
env_var="${3:-}"
|
||||
if [[ -z "$remote_env_path" || -z "$env_var" ]]; then
|
||||
echo "check_service_health_via_ssh: --token-from-env requiere <remote_env_path> <ENV_VAR>" >&2
|
||||
return 2
|
||||
fi
|
||||
shift 3
|
||||
;;
|
||||
--token)
|
||||
token_literal="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--expect-status)
|
||||
expect_status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--connect-timeout)
|
||||
connect_timeout="${2:-5}"
|
||||
shift 2
|
||||
;;
|
||||
--curl-timeout)
|
||||
curl_timeout="${2:-10}"
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "check_service_health_via_ssh: flag desconocida '$1'" >&2
|
||||
return 2
|
||||
;;
|
||||
*)
|
||||
positional+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ssh_host="${positional[0]:-}"
|
||||
local_url="${positional[1]:-}"
|
||||
|
||||
if [[ -z "$ssh_host" || -z "$local_url" ]]; then
|
||||
echo "check_service_health_via_ssh: uso: check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env_path> <ENV_VAR>] [--token <literal>] [--expect-status 200]" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# --- construir el snippet remoto que se ejecuta dentro del host via SSH ---
|
||||
# El token NUNCA se imprime: se resuelve dentro del host remoto y se usa
|
||||
# directamente en el header Authorization. El snippet emite SOLO el http_code.
|
||||
#
|
||||
# Casos de token:
|
||||
# 1) --token-from-env: lee el valor de <ENV_VAR>= del .env remoto.
|
||||
# 2) --token <literal>: el literal se inyecta en el snippet (cuidado: queda
|
||||
# en argv del comando ssh local; preferir --token-from-env para secretos).
|
||||
# 3) sin token: curl sin header Authorization.
|
||||
local remote_script
|
||||
if [[ -n "$remote_env_path" ]]; then
|
||||
# grep el valor del .env remoto, recortando posibles comillas y espacios.
|
||||
remote_script=$(cat <<REMOTE
|
||||
set -e
|
||||
TOKEN=\$(grep -E '^[[:space:]]*${env_var}[[:space:]]*=' '${remote_env_path}' 2>/dev/null | head -n1 | cut -d= -f2- | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*\$//' -e 's/^["'\'']//' -e 's/["'\'']\$//')
|
||||
if [ -z "\$TOKEN" ]; then
|
||||
echo "000"
|
||||
exit 7
|
||||
fi
|
||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
||||
REMOTE
|
||||
)
|
||||
elif [[ -n "$token_literal" ]]; then
|
||||
remote_script=$(cat <<REMOTE
|
||||
set -e
|
||||
TOKEN='${token_literal}'
|
||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
||||
REMOTE
|
||||
)
|
||||
else
|
||||
remote_script=$(cat <<REMOTE
|
||||
set -e
|
||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' 2>/dev/null)"
|
||||
REMOTE
|
||||
)
|
||||
fi
|
||||
|
||||
# --- ejecutar via SSH (o via runner inyectado en tests) ---
|
||||
# CHECK_HEALTH_SSH_BIN permite a los tests sustituir el comando ssh por un
|
||||
# stub que devuelve un http_code fijo, sin tocar la red.
|
||||
local ssh_bin="${CHECK_HEALTH_SSH_BIN:-ssh}"
|
||||
local raw rc=0
|
||||
raw=$("$ssh_bin" -o BatchMode=yes -o ConnectTimeout="$connect_timeout" "$ssh_host" "$remote_script" 2>/dev/null) || rc=$?
|
||||
|
||||
# El snippet remoto, cuando curl -sf falla, emite "<curl_rc>:<http_code>".
|
||||
# Cuando curl tiene exito, emite solo "<http_code>". Normalizamos a http_code.
|
||||
local http_code
|
||||
if [[ "$raw" == *:* ]]; then
|
||||
http_code="${raw##*:}"
|
||||
else
|
||||
http_code="$raw"
|
||||
fi
|
||||
# sanitizar: solo digitos; cualquier otra cosa => 000
|
||||
if [[ ! "$http_code" =~ ^[0-9]+$ ]]; then
|
||||
http_code="000"
|
||||
fi
|
||||
|
||||
# Si el SSH en si fallo (conexion, host caido) y no hay codigo util.
|
||||
local status="ok"
|
||||
if [[ "$rc" -ne 0 && "$http_code" == "000" ]]; then
|
||||
status="error"
|
||||
fi
|
||||
|
||||
# --- decidir healthy ---
|
||||
local healthy="false"
|
||||
if [[ -n "$expect_status" ]]; then
|
||||
[[ "$http_code" == "$expect_status" ]] && healthy="true"
|
||||
else
|
||||
# default: cualquier 2xx
|
||||
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && healthy="true"
|
||||
fi
|
||||
|
||||
printf '{"status":"%s","host":"%s","url":"%s","http_code":%s,"healthy":%s}\n' \
|
||||
"$status" "$ssh_host" "$local_url" "$http_code" "$healthy"
|
||||
|
||||
[[ "$healthy" == "true" ]] && return 0 || return 1
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
check_service_health_via_ssh "$@"
|
||||
fi
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para check_service_health_via_ssh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/check_service_health_via_ssh.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 "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected NOT to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- stub SSH: en vez de conectarse, lee el .env remoto fake (si el snippet lo
|
||||
# referencia) y emite el http_code de la env var STUB_HTTP_CODE. Simula tanto el
|
||||
# caso "curl exito" (solo http_code) como "curl fallo" (<rc>:<http_code>). ---
|
||||
STUB=$(mktemp)
|
||||
chmod +x "$STUB"
|
||||
cat > "$STUB" <<'STUBEOF'
|
||||
#!/usr/bin/env bash
|
||||
# Stub de ssh para tests. Ignora flags -o ... y el host; el ultimo arg es el
|
||||
# script remoto. Emite el codigo segun STUB_HTTP_CODE / STUB_CURL_RC.
|
||||
code="${STUB_HTTP_CODE:-200}"
|
||||
rc="${STUB_CURL_RC:-0}"
|
||||
# Si el script remoto referencia un .env y STUB_TOKEN_EMPTY=1, simular token vacio.
|
||||
if [[ "${STUB_TOKEN_EMPTY:-0}" == "1" ]]; then
|
||||
echo "000"
|
||||
exit 7
|
||||
fi
|
||||
if [[ "$rc" == "0" ]]; then
|
||||
echo "$code"
|
||||
else
|
||||
echo "${rc}:${code}"
|
||||
exit 0
|
||||
fi
|
||||
STUBEOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
FAKE_ENV=$(mktemp)
|
||||
cat > "$FAKE_ENV" <<'ENVEOF'
|
||||
SOME_OTHER=foo
|
||||
AGENTS_API_KEY=supersecret-token-123
|
||||
ANOTHER=bar
|
||||
ENVEOF
|
||||
|
||||
trap 'rm -f "$STUB" "$FAKE_ENV"' EXIT
|
||||
|
||||
# --- Test: service healthy con token desde .env remoto (200 esperado) ---
|
||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
||||
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
||||
assert_contains "service healthy con token desde env remoto" '"healthy":true' "$result"
|
||||
assert_contains "service healthy con token desde env remoto" '"http_code":200' "$result"
|
||||
assert_contains "service healthy con token desde env remoto" '"status":"ok"' "$result"
|
||||
assert_not_contains "service healthy con token desde env remoto" 'supersecret' "$result"
|
||||
|
||||
# --- Test: service no healthy cuando http_code no coincide con expect-status ---
|
||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=503 STUB_CURL_RC=22 \
|
||||
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
||||
assert_contains "service no healthy con http_code 503" '"healthy":false' "$result"
|
||||
assert_contains "service no healthy con http_code 503" '"http_code":503' "$result"
|
||||
|
||||
# --- Test: salida JSON nunca filtra el token ---
|
||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
||||
check_service_health_via_ssh om "http://127.0.0.1:9000/health" \
|
||||
--token literal-secret-xyz) || true
|
||||
assert_not_contains "salida JSON nunca filtra el token" 'literal-secret-xyz' "$result"
|
||||
assert_contains "salida JSON nunca filtra el token" '"healthy":true' "$result"
|
||||
|
||||
# --- Test: sin token y 2xx por defecto cuenta como healthy ---
|
||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=204 \
|
||||
check_service_health_via_ssh om "http://127.0.0.1:8080/ping") || true
|
||||
assert_contains "sin token 2xx por defecto es healthy" '"healthy":true' "$result"
|
||||
assert_contains "sin token 2xx por defecto es healthy" '"http_code":204' "$result"
|
||||
|
||||
# --- Test: falta argumento obligatorio devuelve error de uso ---
|
||||
set +e
|
||||
err=$(check_service_health_via_ssh om 2>&1)
|
||||
ec=$?
|
||||
set -e
|
||||
assert_contains "falta argumento obligatorio devuelve error de uso" 'uso:' "$err"
|
||||
if [[ "$ec" -ne 0 ]]; then
|
||||
echo "PASS: falta argumento sale con codigo distinto de 0"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: falta argumento deberia salir != 0 (got $ec)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -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\""
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
|
||||
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
|
||||
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
|
||||
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture, orchestration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -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,6 +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) | 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 |
|
||||
@@ -56,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 |
|
||||
@@ -64,8 +68,14 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` |
|
||||
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||
| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza |
|
||||
| [eda](eda.md) | 8 | Exploratory Data Analysis por tabla con motor DuckDB push-down: perfil base SQL (SUMMARIZE), estadística numérica/categórica sobre muestra, tipo semántico por regex, score de calidad, render markdown con sparklines y el orquestador one-shot `profile_table` (promueve VARCHAR→numeric/datetime, emite TableProfile + report md/json). Fases siguientes: correlaciones, relaciones inter-tabla, modelos baratos, LLM, notebook |
|
||||
| [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.
|
||||
@@ -19,6 +19,7 @@ Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (pro
|
||||
| `duckdb_table_schema_py_infra` | `duckdb_table_schema(db_path, table) -> dict` | Introspección read-only: schema de una tabla (`DESCRIBE`). Devuelve `{status, table, columns:[{name,type}]}`. Útil para mapear tipos a otro motor (p.ej. PostgreSQL). |
|
||||
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | **Puente de entrada Excel→DuckDB**: ingiere una hoja `.xlsx` a una tabla con la extensión nativa `excel` de DuckDB. `replace`/`append`. Devuelve `{status, table, row_count}`. |
|
||||
| `duckdb_to_postgres_py_pipelines` | `duckdb_to_postgres(duckdb_path, table, pg_dsn, pg_table=None, mode='replace', key_cols=None, batch_size=5000) -> dict` | **Puente de salida DuckDB→Postgres**: mapea tipos, crea la tabla y sincroniza filas. Desbloquea que Metabase/Grafana/Superset (que no hablan DuckDB) lean los datos. Devuelve `{status, pg_table, rows_synced, created}`. |
|
||||
| `query_osint_db_py_datascience` | `query_osint_db(sql, base_url='http://127.0.0.1:8771', timeout=30) -> dict` | **Cliente HTTP del service `osint_db`**: hace `POST {base_url}/api/query` con `{"sql": sql}` y devuelve `{status, columns, rows, row_count, truncated}` sin lanzar (mismo estilo que `duckdb_query_readonly`). Vía correcta para leer la DuckDB maestra del proyecto `osint` desde otro proceso sin abrir el archivo (respeta el single-writer). Service caído → `{status:'error', error}` claro. Solo stdlib. |
|
||||
|
||||
## Puentes: Excel → DuckDB → Postgres → visualización
|
||||
|
||||
@@ -79,7 +80,7 @@ Conversion CSV -> Parquet en una linea:
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
|
||||
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service (`query_osint_db` para `osint_db`). Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
|
||||
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
|
||||
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||
|
||||
|
||||
+100
-48
@@ -1,80 +1,132 @@
|
||||
# eda — Exploratory Data Analysis por tabla
|
||||
# eda — Exploratory Data Analysis por tabla y base
|
||||
|
||||
Grupo de capacidad para perfilar tablas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, outliers).
|
||||
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
||||
|
||||
El orquestador one-shot es `profile_table_py_pipelines`: "hazme un EDA de esta tabla" → un `TableProfile` completo + report markdown + JSON sidecar en `reports/`.
|
||||
Orquestadores one-shot:
|
||||
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
|
||||
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
||||
|
||||
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||
|
||||
## Funciones
|
||||
|
||||
### Perfilado base (tabla y columna)
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `summarize_table_duckdb_py_datascience` | impure | Corazón: `SUMMARIZE` push-down → esqueleto del `TableProfile` con perfil base por columna (tipo inferido, nulls, distinct exacto ≤200k filas, flags). Reusa `duckdb_query_readonly`. |
|
||||
| `describe_numeric_py_datascience` | pure | Bloque `numeric` sobre una muestra: min/max/mean/median/mode/std/cv, percentiles p1-p99, IQR, skew, kurtosis, outliers, %zeros/%neg, tipo de distribución, histograma. |
|
||||
| `summarize_categorical_py_datascience` | pure | Bloque `categorical`: top-k frecuencias, mode, distinct, entropía de Shannon (bits), imbalance, longitudes. |
|
||||
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/integer/decimal/...) sin LLM. Primera pasada barata. |
|
||||
| `column_quality_score_py_datascience` | pure | Score de calidad 0-100 (completeness/validity/consistency) + issues legibles para un `ColumnProfile`. |
|
||||
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown autosuficiente (Overview, Columnas, Numéricas con sparkline ASCII, Categóricas, Calidad). |
|
||||
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75) de una lista de floats. |
|
||||
| `profile_table_py_pipelines` | pipeline | Orquestador end-to-end: compone todo lo anterior, promueve tipos VARCHAR→numeric/datetime por contenido, y emite `TableProfile` + report markdown + JSON. |
|
||||
| `summarize_table_duckdb_py_datascience` | impure | Corazón (DuckDB): `SUMMARIZE` push-down + `COUNT DISTINCT` exacto (≤200k filas) → esqueleto del `TableProfile`. |
|
||||
| `summarize_table_pg_py_datascience` | impure | Adaptador PostgreSQL: mismo esqueleto `TableProfile` vía SQL push-down (information_schema + count/distinct/min/max/avg/stddev/percentile_cont). |
|
||||
| `describe_numeric_py_datascience` | pure | Bloque numérico: min/max/mean/median/std/cv, p1-p99, IQR, skew, kurtosis, outliers, distribución, histograma. |
|
||||
| `summarize_categorical_py_datascience` | pure | top-k frecuencias, mode, distinct, entropía, imbalance, longitudes. |
|
||||
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/...). |
|
||||
| `column_quality_score_py_datascience` | pure | Score 0-100 (completeness/validity/consistency) + issues. |
|
||||
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown con sparklines ASCII. |
|
||||
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75). |
|
||||
|
||||
### Correlación / asociación
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `pearson_py_datascience` | pure | Correlación lineal num↔num (preexistente). |
|
||||
| `spearman_corr_py_datascience` | pure | Correlación de rangos (monotónica no lineal) num↔num. |
|
||||
| `cramers_v_py_datascience` | pure | Asociación simétrica cat↔cat (corrección Bergsma-Wicher). |
|
||||
| `theils_u_py_datascience` | pure | Asociación direccional U(a\|b) cat↔cat. |
|
||||
| `correlation_ratio_py_datascience` | pure | η: cuánto explica una categórica a una numérica. |
|
||||
| `mutual_info_columns_py_datascience` | pure | Información mutua (no lineal, general) entre cualquier par. |
|
||||
| `association_matrix_py_datascience` | pure | Matriz unificada: elige métrica por par de tipos + pares fuertes. |
|
||||
| `correlation_matrix_duckdb_py_datascience` | impure | Matriz Pearson push-down (`corr()` SQL) para muchas filas. |
|
||||
|
||||
### Relaciones inter-tabla
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `infer_fk_containment_duckdb_py_datascience` | impure | Infiere FK candidatas por containment de valores (inclusion coefficient). |
|
||||
| `build_join_graph_py_datascience` | pure | FK candidates → grafo (roles fact/dimension) + diagrama Mermaid. |
|
||||
|
||||
### Modelos baratos (flag `run_models`)
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `pca_explained_py_datascience` | pure | PCA: varianza explicada + loadings + proyección. |
|
||||
| `kmeans_segments_py_datascience` | pure | Segmentos naturales, auto-k por silhouette. |
|
||||
| `isolation_forest_outliers_py_datascience` | pure | Outliers multivariante (filas anómalas). |
|
||||
| `normality_tests_py_datascience` | pure | Jarque-Bera + D'Agostino + Shapiro → ¿normal? |
|
||||
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
||||
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
||||
|
||||
### Capa LLM y entrega
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
||||
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
||||
|
||||
### Orquestadores (pipelines)
|
||||
| ID | Qué hace |
|
||||
|---|---|
|
||||
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
|
||||
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||
|
||||
## Contrato de datos
|
||||
|
||||
Todas las funciones producen/consumen el mismo shape (dict JSON), lo que desacopla cálculo, render y (futuro) LLM:
|
||||
|
||||
```
|
||||
TableProfile = {
|
||||
table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||
duplicate_rows, duplicate_pct, constant_cols:[str], all_null_cols:[str],
|
||||
null_cell_pct, type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates:[str],
|
||||
quality_score, llm, models
|
||||
}
|
||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
||||
|
||||
ColumnProfile = {
|
||||
name, physical_type, inferred_type, # numeric|categorical|datetime|boolean|text|id
|
||||
semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct,
|
||||
distinct_count, unique_pct, # *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null],
|
||||
quality_score,
|
||||
numeric: {min,max,mean,median,mode,std,variance,cv,p1,p5,p25,p50,p75,p95,p99,iqr,
|
||||
skew,kurtosis,n_outliers,outlier_pct,zero_pct,negative_pct,distribution_type,
|
||||
histogram:[{lo,hi,count}]} | None,
|
||||
categorical: {top:[{value,count,pct}],mode,mode_pct,n_distinct,entropy,imbalance,
|
||||
len_mean,len_min,len_max} | None,
|
||||
datetime: {min,max,range_days,granularity,n_gaps,future_pct,monotonic} | None
|
||||
}
|
||||
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
||||
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
|
||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
||||
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||
```
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
EDA de una tabla DuckDB en una línea (escribe `reports/eda_<table>_<ts>.md` + `.json`):
|
||||
EDA completo de una tabla (estadística + correlación + modelos + LLM + report):
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.profile_table import profile_table
|
||||
|
||||
r = profile_table(os.path.expanduser("~/.fn_freelance/freelance.duckdb"), "freelance_projects")
|
||||
print(r["status"], r["report_md_path"])
|
||||
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
||||
prof = r["profile"]
|
||||
print(prof["type_breakdown"], "key_candidates:", prof["key_candidates"], "calidad:", prof["quality_score"])
|
||||
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
||||
print(prof["correlations"]["strong"]) # pares correlacionados
|
||||
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
||||
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
||||
```
|
||||
|
||||
La promoción de tipo por contenido resuelve el caso típico de scrapers/CSV donde los números y fechas llegan como `VARCHAR`: `bids` ('10','20') se detecta `integer` y se perfila como numérica (mean/median/percentiles); `scraped_at` se detecta `datetime_iso`.
|
||||
EDA de una base entera con relaciones:
|
||||
|
||||
```python
|
||||
from pipelines.profile_database import profile_database
|
||||
r = profile_database("/ruta/datos.duckdb") # todas las tablas
|
||||
print(r["db_profile"]["join_graph"]["mermaid"]) # diagrama de relaciones FK
|
||||
```
|
||||
|
||||
Notebook ejecutable:
|
||||
|
||||
```python
|
||||
from datascience import build_eda_notebook
|
||||
build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_models=True)
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO carga la tabla entera a RAM**: solo metadata SQL + una muestra (`sample`, default 5000) por columna. Para distribución exacta de una columna enorme, sube `sample` o consulta SQL directa.
|
||||
- **Distinct exacto solo hasta 200k filas**; por encima usa aproximado (HyperLogLog) capado a nº de filas.
|
||||
- **Solo DuckDB** por ahora (CSV/Parquet/Excel entran gratis vía `read_csv_auto`/`read_parquet`/`read_xlsx` cargándolos antes a DuckDB). PostgreSQL y BigQuery requieren adaptador (pendiente).
|
||||
- **No es estadística inferencial ni modelado**: es perfilado descriptivo. Correlaciones, modelos baratos (PCA/KMeans/IsolationForest) y capa LLM son fases siguientes del grupo.
|
||||
- **NO carga la tabla entera a RAM**: metadata SQL + muestra por columna/filas (`sample`, default 5000).
|
||||
- **Distinct exacto hasta 200k filas**; por encima aproximado capado.
|
||||
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
||||
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
||||
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
||||
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||
|
||||
## Roadmap (fases siguientes)
|
||||
## Estado
|
||||
|
||||
- **Correlación / asociación**: Spearman, Cramér's V, Theil's U, correlation ratio η², Mutual Information, VIF → `correlations` del `TableProfile`.
|
||||
- **Relaciones inter-tabla**: FK inference por containment, cardinalidad de relación, join graph (mermaid), star-schema hints → `profile_database`.
|
||||
- **Modelos baratos** (flag `--models`, sklearn/scipy): PCA 2D, KMeans + silhouette, Isolation Forest, feature importance, tests de normalidad, tendencia temporal.
|
||||
- **Capa LLM** (flag `--llm`, grupo `claude-direct`): data dictionary, resumen ejecutivo (qué es 1 fila + granularidad), flag PII/RGPD, limpieza sugerida, análisis sugeridos.
|
||||
- **Entrega notebook**: analysis Jupyter auto-generado y ejecutado en el navegador colaborativo.
|
||||
Implementado y validado end-to-end (152 tests verdes): perfilado base, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK + join graph), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM y generación de notebook.
|
||||
|
||||
Validado sobre PostgreSQL real (tablas del Metabase local del proyecto captacion_clientes).
|
||||
|
||||
Pendiente: adaptador BigQuery; `profile_database` multi-tabla para PostgreSQL (hoy solo DuckDB); perfil fino de columnas datetime (`profile_datetime`); excluir columnas numéricas `possible_id` de la matriz de asociación (hoy solo se excluyen las categóricas id-like).
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
group: img-to-3d
|
||||
description: "Convertir una imagen 2D en un modelo 3D: estimacion de profundidad monocular (Depth-Anything-V2) + reconstruccion de una malla de relieve texturizada exportada a glTF binario (.glb)."
|
||||
---
|
||||
|
||||
# img-to-3d — Capability Group
|
||||
|
||||
Cluster de funciones Python (dominio `datascience`) para el flujo **imagen 2D → modelo 3D**. A
|
||||
partir de una sola foto se estima un mapa de profundidad monocular con un modelo de vision y se
|
||||
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 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:
|
||||
|
||||
```
|
||||
[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?, 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 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. `remove_background` en
|
||||
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||
|
||||
## Ejemplo canonico (end-to-end imagen → glb)
|
||||
|
||||
```python
|
||||
# Requiere un venv con torch + transformers + trimesh + pillow + numpy.
|
||||
# Import PLANO: el paquete datascience.__init__ arrastra deps de otros dominios (bs4, duckdb...)
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
```
|
||||
|
||||
O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
||||
|
||||
```bash
|
||||
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
|
||||
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, ...}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **Es relieve 2.5D, no reconstruccion volumetrica.** Deforma un plano segun la profundidad
|
||||
(heightmap); no recupera caras ocultas ni el volumen trasero del objeto. Para 3D real
|
||||
multivista/fotogrametria, NSP/Gaussian Splatting, esto NO aplica.
|
||||
- **Profundidad relativa, no metrica.** Depth-Anything devuelve disparidad normalizada a [0,1];
|
||||
no comparable entre imagenes ni en unidades del mundo real.
|
||||
- **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), `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 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,77 @@
|
||||
# Capability: local-hub
|
||||
|
||||
Exponer los procesos locales de la maquina como subdominios `*.localhost` (via Caddy) y reunirlos
|
||||
en una pantalla principal (Glance) con estado online/offline en vivo, refrescada a diario por
|
||||
`dag_engine`. Cubre el ciclo: descubrir servicios -> renderizar config de proxy -> renderizar
|
||||
config de dashboard -> recargar y reiniciar (pipeline `refresh_local_hub`).
|
||||
|
||||
Fuente de verdad de los servicios: `apps/local_hub/local_services.yaml`.
|
||||
|
||||
## Por que existe
|
||||
|
||||
Una maquina con muchos procesos locales (Metabase :3030, Portainer :9000, Grafana, Jupyter,
|
||||
dag_engine, registry_api...) obliga a recordar puerto por puerto. Este grupo los pone detras de
|
||||
nombres legibles (`metabase.localhost`, `portainer.localhost`) sin tocar DNS ni `/etc/hosts`
|
||||
(systemd-resolved resuelve `*.localhost` a 127.0.0.1 por defecto, RFC 6761) y los lista en una
|
||||
sola pagina con su salud en vivo.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `discover_local_services_py_infra` | `discover_local_services(manifest_path, include_registry=True) -> list[dict]` | Lee el manifiesto `local_services.yaml`, normaliza cada servicio (name, subdomain, port, health_path, title, icon, category) y resuelve `up` por chequeo TCP. Con `include_registry` anade los servicios del registry (via `fn doctor services-spec`) deduplicados. |
|
||||
| `render_caddyfile_py_infra` | `render_caddyfile(services, dashboard=None) -> str` | PURA. Convierte la lista de servicios en el texto de un fragmento de Caddyfile (`http://<sub>.localhost { reverse_proxy 127.0.0.1:<port> }`), ordenado y determinista. El dashboard va primero. |
|
||||
| `render_glance_config_py_infra` | `render_glance_config(services, title="Procesos locales", host_suffix="localhost") -> str` | PURA. Convierte la lista en YAML de Glance: una pagina con un widget `monitor` por categoria, cada site apuntando a `http://<sub>.localhost`. |
|
||||
| `refresh_local_hub_py_pipelines` | `refresh_local_hub(manifest_path=..., reload=True) -> dict` | PIPELINE. Compone las 3 anteriores: descubre, renderiza Caddyfile + Glance, los escribe (`/etc/caddy/conf.d/local_hub.caddy` via ACL + `apps/local_hub/glance/glance.yml`), recarga Caddy (admin API :2019, sin sudo) y reinicia la user-unit `glance`. |
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
```bash
|
||||
# Refrescar todo el hub (descubrir + regenerar configs + recargar):
|
||||
./fn run refresh_local_hub
|
||||
|
||||
# Acceder a un servicio por su subdominio (cualquier navegador del host):
|
||||
# http://metabase.localhost
|
||||
# http://portainer.localhost
|
||||
# http://home.localhost <- la pantalla principal (Glance)
|
||||
|
||||
# Anadir un servicio nuevo: editar el manifiesto y refrescar
|
||||
$EDITOR apps/local_hub/local_services.yaml # name, subdomain, port, health_path, title, icon, category
|
||||
./fn run refresh_local_hub
|
||||
```
|
||||
|
||||
Composicion ad-hoc (heredoc) si se necesita solo una parte:
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.discover_local_services import discover_local_services
|
||||
from infra.render_caddyfile import render_caddyfile
|
||||
|
||||
services = discover_local_services("apps/local_hub/local_services.yaml")
|
||||
print(render_caddyfile(services, dashboard={"subdomain": "home", "port": 8585}))
|
||||
```
|
||||
|
||||
## Infraestructura (one-time, ya provisionada)
|
||||
|
||||
- **Caddy** (`apt`, systemd system service, puerto 80): `/etc/caddy/Caddyfile` hace
|
||||
`import /etc/caddy/conf.d/*.caddy`. El usuario tiene ACL de escritura sobre `conf.d/` para que
|
||||
el pipeline regenere sin sudo. Reload via admin API en `localhost:2019`.
|
||||
- **Glance** (binario nativo en `~/.local/bin/glance`, systemd user service `glance.service`,
|
||||
`127.0.0.1:8585`). Corre como binario del host —no contenedor— para que `*.localhost` resuelva
|
||||
igual que en el resto del sistema.
|
||||
- **dag_engine**: DAG `refresh_local_hub` diario que ejecuta el pipeline.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO gestiona TLS**: sirve HTTP plano (`http://`) porque es trafico loopback. Para HTTPS con CA
|
||||
interna habria que quitar el prefijo `http://` en `render_caddyfile` y dejar que Caddy emita
|
||||
certs internos.
|
||||
- **NO arranca ni para los servicios** que expone: asume que ya corren. Solo crea el mapeo de
|
||||
subdominio y los lista. Encender/apagar un servicio es trabajo de su propia unit / `systemd`.
|
||||
- **NO hace el health-check en vivo**: eso lo hace Glance client-side. El pipeline solo resuelve
|
||||
un `up/down` puntual al regenerar (snapshot del momento).
|
||||
- **Servicios no-HTTP** (Postgres :5433, etc.) quedan fuera del proxy y del dashboard: Caddy y el
|
||||
widget `monitor` de Glance son HTTP.
|
||||
- **Solo loopback / un PC**: el manifiesto y los subdominios son locales a la maquina. No expone
|
||||
nada a la red. Para acceso remoto se usa SSH port-forward o el grupo `ssh`/`wireguard`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Capability: metabase
|
||||
|
||||
Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 106 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`).
|
||||
Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth` con email/password, `metabase_client_from_pass` cargando la credencial desde `pass` — sesión o API-key), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 108 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`) — el cliente Py detecta el prefijo `mb_` y autentica por header `X-API-KEY`.
|
||||
|
||||
## Funciones
|
||||
|
||||
@@ -15,6 +15,8 @@ Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/
|
||||
| `metabase_archive_snippet_py_infra` | `def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict` | Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True. |
|
||||
| `metabase_auth_go_infra` | `func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session. |
|
||||
| `metabase_auth_py_infra` | `def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session. |
|
||||
| `metabase_client_from_pass_py_infra` | `def metabase_client_from_pass(pass_key: str, base_url: str, mode: str = 'auto') -> MetabaseClient \| dict` | Construye un `MetabaseClient` autenticado leyendo la credencial desde `pass`. `mode='session'` (secreto multi-línea: L1 password, línea `email:`) usa `metabase_auth`; `mode='api_key'` (secreto de una línea tipo `mb_...`) autentica por header; `mode='auto'` detecta por la forma del secreto. Compone `pass_get_secret` + `parse_metabase_secret` + `metabase_auth`. Devuelve el cliente o `{status:'error', error}` sin lanzar. Cubre Aurgi (API-key) y captación (sesión) sin reescribir la carga de credenciales. |
|
||||
| `parse_metabase_secret_py_infra` | `def parse_metabase_secret(secret_text: str, mode: str = 'auto') -> dict` | Núcleo **puro** y testeable de `metabase_client_from_pass`: parsea el texto del secreto de `pass` y devuelve `{mode, email, password}` (sesión) o `{mode, api_key}` (API-key). `mode='auto'` clasifica: una sola línea sin `email:`/`login:` → api_key; multi-línea con email → session. Sin I/O. |
|
||||
| `metabase_copy_card_py_infra` | `def metabase_copy_card(client: MetabaseClient, card_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None) -> dict` | Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia. |
|
||||
| `metabase_copy_dashboard_py_infra` | `def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None, is_deep_copy: bool = False) -> dict` | Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas. |
|
||||
| `metabase_copy_dashcard_mappings_py_infra` | `def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]` | Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping. |
|
||||
@@ -134,6 +136,22 @@ dash = metabase_get_dashboard(client, dashboard_id=42)
|
||||
cards = metabase_list_cards(client, collection_id=dash["collection_id"])
|
||||
```
|
||||
|
||||
### Cliente autenticado desde `pass` (sin manejar credenciales a mano)
|
||||
|
||||
```python
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))
|
||||
from metabase import metabase_client_from_pass, metabase_get_dashboard
|
||||
|
||||
# Aurgi: API-key de una línea en pass (mb_...)
|
||||
client = metabase_client_from_pass("metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
|
||||
|
||||
# Captación: secreto multi-línea (password + email:) → sesión
|
||||
# client = metabase_client_from_pass("captacion/metabase", "http://localhost:3030", mode="session")
|
||||
|
||||
dash = metabase_get_dashboard(client, dashboard_id=734)
|
||||
```
|
||||
|
||||
### Crear card + dashboard + ejecutar (Go)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,12 +15,22 @@ Postgres es la **capa que sirve datos a las herramientas de BI** del stack (`Exc
|
||||
| `pg_create_table_from_rows_py_infra` | `pg_create_table_from_rows(dsn, table, rows, primary_key=None) -> dict` | `CREATE TABLE IF NOT EXISTS` infiriendo columnas y tipos desde los valores (bool→BOOLEAN, int→BIGINT, float→DOUBLE PRECISION, datetime→TIMESTAMP, date→DATE, resto→TEXT). Idempotente. Devuelve `{status, created, table, columns}`. |
|
||||
| `pg_list_tables_py_infra` | `pg_list_tables(dsn, schema='public') -> dict` | Introspección read-only: tablas base con sus columnas vía `information_schema`. Devuelve `{status, schema, tables:[{name, columns:[{name,type,nullable}]}]}`. |
|
||||
| `pg_apply_sql_py_infra` | `pg_apply_sql(dsn, sql_path) -> int` | Ejecuta un archivo `.sql` completo (multi-statement, una transacción). Para migraciones idempotentes (`IF NOT EXISTS`). |
|
||||
| `resolve_pg_dsn_py_infra` | `resolve_pg_dsn(project) -> dict` | Resuelve el DSN de Postgres de un proyecto conocido (`captacion`/`captacion_clientes` vía `CAPTACION_DSN`, `seo`/`seo_analytics` vía `SEO_DSN`) en este orden: (1) variable de entorno, (2) línea `<ENV_VAR>=` del `.env` del proyecto, (3) construido desde `pass` en runtime. Devuelve `{status, project, dsn, source}` (`source` = `env`\|`dotenv`\|`pass`) sin lanzar. Mapa de proyectos explícito en el código — añadir un proyecto = editar `_PROJECTS`. Nunca hardcodea el password. |
|
||||
| `query_project_pg_py_pipelines` | `query_project_pg(project, sql, max_rows=10000) -> dict` | **Pipeline one-shot**: compone `resolve_pg_dsn` + `pg_query`. Lee el DSN del proyecto y ejecuta el SELECT en un solo paso, sin que el caller toque el DSN. Devuelve lo de `pg_query` (`{status, columns, rows, row_count, truncated}`) o propaga el error de resolución. Reemplaza el patrón inline de resolver el DSN a mano antes de consultar. |
|
||||
|
||||
Relacionadas (otros grupos): `duckdb_to_postgres_py_pipelines` (sincroniza una tabla DuckDB a Postgres) e `init_metabase_go_infra` (despliega el stack Metabase + Postgres en Docker).
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
Crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`):
|
||||
Atajo de un paso — consultar un proyecto conocido sin tocar el DSN (resuelto desde `.env`/`pass`):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
./fn run query_project_pg captacion "SELECT COUNT(*) AS n FROM product_opportunities"
|
||||
# {"status":"ok","columns":["n"],"rows":[{"n":19}],"row_count":1,"truncated":false}
|
||||
```
|
||||
|
||||
Camino completo — crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
@@ -42,7 +52,7 @@ PYEOF
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`). No imprimas el DSN en logs.
|
||||
- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`), o mejor con `resolve_pg_dsn(project)` que centraliza la convención por proyecto. No imprimas el DSN en logs. Para proyectos no mapeados en `resolve_pg_dsn`, pasa el DSN a `pg_query` directamente.
|
||||
- **`pg_query`/`pg_list_tables` son read-only por convención** (`SET TRANSACTION READ ONLY` + rollback), protegen la base pero NO son sandbox; los identificadores (tabla/schema) NO se parametrizan — los valores sí (`%s`). Las funciones validan identificadores con `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||
- **`pg_upsert` cuenta insert vs update con el pseudo-columna `xmax`** (`RETURNING (xmax = 0)`). Fiable en el caso normal (single-writer, sin triggers raros). Con `update_cols=[]` (DO NOTHING) las filas en conflicto no se devuelven, así que solo se cuentan las nuevas. BEFORE-triggers / REPLICA IDENTITY pueden desviar el conteo.
|
||||
- **`pg_create_table_from_rows` no reconcilia schema:** si la tabla ya existe, `columns` reporta los tipos inferidos de las filas, no los reales. Inferencia best-effort sin NUMERIC/escala — para dinero define el schema a mano con `pg_apply_sql`.
|
||||
|
||||
@@ -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.
|
||||
@@ -7,6 +7,7 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `audit_ssh_config_bash_cybersecurity` | `audit_ssh_config(config_path: string) -> void` | Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual. |
|
||||
| `check_service_health_via_ssh_bash_infra` | `check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env> <VAR>] [--token <literal>] [--expect-status 200]` | Comprueba la salud de un service HTTP que solo escucha en loopback de un host remoto: entra por SSH, lee opcionalmente un bearer token de un `.env` remoto, y hace `curl` al endpoint local con `Authorization: Bearer`. Emite JSON (`{status, host, url, http_code, healthy}`), exit 0 si sano. El token nunca se imprime; prefiere `--token-from-env` sobre `--token` (este deja el secreto en argv local). |
|
||||
| `docker_compose_remote_deploy_bash_infra` | `docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json` | Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion. |
|
||||
| `rsync_deploy_bash_infra` | `rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json` | Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe. |
|
||||
| `setup_registry_api_bash_infra` | `setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json` | Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check. |
|
||||
@@ -50,6 +51,15 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
|
||||
./fn run wait_for_http https://myapp.example.com/health 30
|
||||
```
|
||||
|
||||
### Health-check de un service que solo escucha en loopback del host remoto
|
||||
|
||||
```bash
|
||||
./fn run check_service_health_via_ssh om "http://127.0.0.1:8487/agents" \
|
||||
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
|
||||
--expect-status 200
|
||||
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agents","http_code":200,"healthy":true}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`.
|
||||
|
||||
@@ -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).
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func ClassifyFleetTermination(status, phase, dodContract, dodStatus string, idleSeconds, stallThresholdSeconds int) string"
|
||||
description: "Clasifica MECANICAMENTE el estado de terminacion de un agente Claude de la flota para que un watcher barato sin LLM decida que hacer. Pura y determinista. Devuelve una de RECLAMA, MAL_LANZADO, DICE_TERMINADO, ESTANCADO o TRABAJANDO segun precedencia fija: RECLAMA (pide input humano) manda sobre todo, luego MAL_LANZADO (sin DoD-contrato), luego DICE_TERMINADO, ESTANCADO y TRABAJANDO."
|
||||
tags: [fleet, claude-fleet, classification, watcher, termination, orchestrator, pure, infra]
|
||||
tags: [fleet, claude-fleet, classification, watcher, termination, orchestrator, pure, infra, orchestration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -24,7 +24,8 @@ type ClaudeFleet struct {
|
||||
Name string `json:"name"` // manual rename of the terminal (from goals .rename; "" if none)
|
||||
Status string `json:"status"` // idle|busy|waiting (from sessions/<pid>.json)
|
||||
Cwd string `json:"cwd"` // working directory of the session
|
||||
TmuxWindow string `json:"tmux_window"` // "" for now (populated in a later phase)
|
||||
TmuxWindow string `json:"tmux_window"` // window_id (@N) of the pane: REAL current position, used for focus/send-keys; migrates when the pane is swapped between windows
|
||||
PaneID string `json:"pane_id"` // pane_id (%N) of the pane: STABLE identity for the pane's whole life, immune to window swaps; "" if not resolvable. Prefer this as the agent's identifier over TmuxWindow
|
||||
Alive bool `json:"alive"` // process alive AND procStart matches (guards against PID recycling)
|
||||
UpdatedAt int64 `json:"updated_at"` // from sessions/<pid>.json .updatedAt (epoch millis)
|
||||
CtxPct int `json:"ctx_pct"` // context window used %, from runtime/<sessionId>.json; -1 if unknown
|
||||
|
||||
@@ -42,13 +42,47 @@ type runtimeFile struct {
|
||||
|
||||
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
||||
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
||||
// ListClaudeFleetFrom resolving the home directory.
|
||||
// ListClaudeFleetFrom resolving the home directory, plus it populates each
|
||||
// member's PaneID ("%N") by resolving it against the fleet tmux socket.
|
||||
//
|
||||
// The socket comes from $FLEET_SOCKET, defaulting to "fleet". Resolution is
|
||||
// best-effort: if tmux/the socket is unavailable, every PaneID is left "" and
|
||||
// the fleet is still returned. PaneID is only populated here (the public
|
||||
// registry entry point), not in ListClaudeFleetFrom, so consumers that call the
|
||||
// core directly in a hot loop (and the unit tests) pay no tmux cost.
|
||||
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
fleet, err := ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
populatePaneIDs(fleet)
|
||||
return fleet, nil
|
||||
}
|
||||
|
||||
// populatePaneIDs resolves each alive member's pane_id ("%N") against the fleet
|
||||
// tmux socket ($FLEET_SOCKET, default "fleet") and writes it into PaneID. It
|
||||
// mutates fleet in place. Best-effort: tmux/socket down -> every PaneID stays ""
|
||||
// (ResolvePaneIDs returns an empty map), no crash. Only alive PIDs are queried;
|
||||
// a dead PID has no pane to resolve.
|
||||
func populatePaneIDs(fleet []ClaudeFleet) {
|
||||
socket := os.Getenv("FLEET_SOCKET")
|
||||
if socket == "" {
|
||||
socket = "fleet"
|
||||
}
|
||||
pids := make([]int, 0, len(fleet))
|
||||
for _, f := range fleet {
|
||||
if f.Alive {
|
||||
pids = append(pids, f.PID)
|
||||
}
|
||||
}
|
||||
byPID := ResolvePaneIDs(socket, pids)
|
||||
for i := range fleet {
|
||||
fleet[i].PaneID = byPID[fleet[i].PID]
|
||||
}
|
||||
}
|
||||
|
||||
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
||||
|
||||
@@ -7,8 +7,8 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) | func ListClaudeFleet() ([]ClaudeFleet, error)"
|
||||
description: "Lista la flota de procesos Claude Code de la maquina local (Linux). Escanea ~/.claude/sessions/*.json, cruza cada PID vivo contra /proc para validar liveness (anti-PID-reciclado via procStart == campo 22 de /proc/<pid>/stat), une el goal/phase de ~/.claude/goals/<sessionId>.json, extrae KITTY_PID del environ y deriva los campos de display (Target, Rename). Devuelve todas las sesiones ordenadas por status (idle, waiting, busy, otro) y por updatedAt desc; el caller filtra por Alive. Pieza de datos de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, proc, fleet, tui]
|
||||
uses_functions: []
|
||||
tags: [claude-fleet, infra, claude, session, proc, fleet, tui, orchestration]
|
||||
uses_functions: [resolve_pane_ids_go_infra]
|
||||
uses_types: [claude_fleet_go_infra]
|
||||
returns: [claude_fleet_go_infra]
|
||||
returns_optional: false
|
||||
@@ -17,7 +17,7 @@ imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListClaudeFleetFrom lo recibe explicito (testeable con t.TempDir()); ListClaudeFleet lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), Alive y UpdatedAt. Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), PaneID, Alive y UpdatedAt. ListClaudeFleet() puebla PaneID (\"%N\", identificador estable del pane) cruzando cada PID vivo con los panes del socket $FLEET_SOCKET (default \"fleet\") via resolve_pane_ids_go_infra; ListClaudeFleetFrom() deja PaneID \"\" (no hace tmux). Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListClaudeFleetFrom", "TestListClaudeFleetFromMissingDir"]
|
||||
test_file_path: "functions/infra/list_claude_fleet_test.go"
|
||||
@@ -69,4 +69,5 @@ Cuando necesites enumerar las sesiones de Claude Code vivas en la maquina local
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` y `/proc/<pid>/environ` (Linux). En macOS/BSD no funciona tal cual.
|
||||
- **environ ilegible -> KittyPID=0.** Si `/proc/<pid>/environ` no es legible (permisos, proceso de otro usuario, o el proceso ya murio entre el ReadDir y el ReadFile) `KittyPID` cae a 0 sin error. Tambien es 0 legitimamente cuando claude no corre bajo kitty (ej. tmux remoto).
|
||||
- **Devuelve TODAS las sesiones con JSON parseable**, vivas o muertas. El caller decide filtrar por `Alive`. Archivos no-`.json` y JSON corrupto se ignoran silenciosamente.
|
||||
- **TmuxWindow siempre "".** Reservado para una fase posterior; hoy no se rellena.
|
||||
- **TmuxWindow siempre "".** Esta funcion no resuelve el window_id (@N); lo rellena el consumidor (fleetview) cuando lo necesita para el focus.
|
||||
- **PaneID lo puebla solo `ListClaudeFleet()`, no `ListClaudeFleetFrom()`.** La variante con directorio (usada en tests y en bucles de render calientes) no llama a tmux: deja `PaneID` "". La publica resuelve el pane_id ("%N") contra `$FLEET_SOCKET` (default "fleet") via `resolve_pane_ids_go_infra`. Si el socket no existe o tmux no responde, todos los `PaneID` quedan "" sin error. El pane_id es estable de por vida del pane (inmune a los swaps de window que mueve el focus), a diferencia de `TmuxWindow`.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// NotifyDesktop sends a desktop notification on Linux via the `notify-send`
|
||||
// binary (libnotify). It is impure: it shells out to an external program.
|
||||
//
|
||||
// Degradation is intentional and silent: if `notify-send` is not on the PATH,
|
||||
// the function returns nil without error. A machine without a notification
|
||||
// server is not a failure condition for the caller — the notification is simply
|
||||
// skipped. Only a real execution failure of an existing `notify-send` is
|
||||
// returned (wrapped with context).
|
||||
//
|
||||
// When `notify-send` is present it runs:
|
||||
//
|
||||
// notify-send --app-name=fleetview --urgency=normal -- <title> <body>
|
||||
//
|
||||
// The `--` separator guarantees that a title or body starting with "-" is
|
||||
// treated as positional text, not as a flag. An empty title falls back to a
|
||||
// sensible default; an empty body is accepted by notify-send as-is.
|
||||
func NotifyDesktop(title, body string) error {
|
||||
bin, err := exec.LookPath("notify-send")
|
||||
if err != nil {
|
||||
// No notification server / binary on this machine: skip silently.
|
||||
return nil
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = "Notificación"
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "--app-name=fleetview", "--urgency=normal", "--", title, body)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("notify-send failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: notify_desktop
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func NotifyDesktop(title, body string) error"
|
||||
description: "Lanza una notificacion de escritorio en Linux via el binario notify-send (libnotify). Degradacion limpia: si notify-send no esta en el PATH devuelve nil sin error (no es fallo que la maquina no tenga servidor de notificaciones). Cuando existe ejecuta: notify-send --app-name=fleetview --urgency=normal -- <title> <body>, usando -- para que un texto que empiece por - no se interprete como flag. title vacio cae a un default; body puede ir vacio."
|
||||
tags: [orchestration, notify, infra, desktop, libnotify]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "os/exec"]
|
||||
params:
|
||||
- name: title
|
||||
desc: "titulo de la notificacion; si es cadena vacia usa el default 'Notificación'"
|
||||
- name: body
|
||||
desc: "cuerpo de la notificacion; puede ir vacio (notify-send lo acepta)"
|
||||
output: "error: nil si la notificacion se mostro o si notify-send no esta instalado (degradacion silenciosa); error envuelto con contexto solo si la ejecucion real de notify-send falla"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/notify_desktop.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Avisar al usuario en el escritorio de que un agente termino.
|
||||
err := infra.NotifyDesktop("✅ Agente terminó", "EDA dataset X — revísalo")
|
||||
if err != nil {
|
||||
// notify-send existe pero fallo al ejecutarse
|
||||
log.Printf("no se pudo notificar: %v", err)
|
||||
}
|
||||
// En una maquina sin notify-send, err es nil y la notificacion se omite.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para avisar al usuario en el escritorio cuando un proceso largo o un agente termina su trabajo (fin de un EDA, build, deploy, o tarea desatendida del orquestador). Es el toque final tras una operacion que el humano no esta mirando en directo: dispara la notificacion y sigue, sin preocuparte de si la maquina destino tiene servidor de notificaciones.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo Linux con servidor de notificaciones (libnotify).** Depende del binario `notify-send`; en otros SO no aplica.
|
||||
- **Headless / sin DBUS no muestra nada pero NO falla.** Si `notify-send` no esta en el PATH, devuelve `nil` (degradacion silenciosa): el caller no se rompe por carecer de notificaciones.
|
||||
- **Requiere sesion grafica activa.** Aunque `notify-send` exista, sin una sesion grafica con DBUS la notificacion puede no aparecer; en ese caso `Run()` puede devolver error real, que se devuelve envuelto.
|
||||
- **`--` antes de los argumentos posicionales** evita que un `title`/`body` que empiece por `-` se interprete como flag. No lo quites.
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolvePaneIDs crosses the PID of each Claude process with the panes of the
|
||||
// given isolated tmux socket (tmux -L <socket> list-panes -a) and returns a
|
||||
// claudePID -> pane_id ("%N") map.
|
||||
//
|
||||
// The pane_id is STABLE for the pane's whole life: it identifies a Claude even
|
||||
// when its window_id (@N) migrates with the focus swap (break-pane + join-pane
|
||||
// move the pane between windows). That is why it is the correct stable handle
|
||||
// for an agent, as opposed to the window_id which changes by design.
|
||||
//
|
||||
// For each claudePID it climbs the process tree (PPID in /proc/<pid>/stat) until
|
||||
// it finds a PID that is a pane_pid of the socket; that pane is the one hosting
|
||||
// the Claude. Normally the pane_pid IS the claudePID because the pane runs
|
||||
// `exec claude`, but a shell that launched claude as a child is covered by the
|
||||
// ascent.
|
||||
//
|
||||
// Best-effort and crash-free: an empty socket, no PIDs, or a tmux failure
|
||||
// (socket down, tmux absent) all yield an empty map; a Claude with no resolvable
|
||||
// pane is simply omitted from the result (callers degrade it to ""). It reads
|
||||
// /proc, hence the //go:build !windows tag.
|
||||
func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string {
|
||||
if socket == "" || len(claudePIDs) == 0 {
|
||||
return map[int]string{}
|
||||
}
|
||||
out, _, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{pane_id}")
|
||||
if err != nil {
|
||||
return map[int]string{}
|
||||
}
|
||||
return resolvePaneIDsFrom(out, procPPID, claudePIDs)
|
||||
}
|
||||
|
||||
// resolvePaneIDsFrom is the testable core of ResolvePaneIDs: it parses the
|
||||
// `<pane_pid> <pane_id>` lines produced by tmux and, for each claudePID, climbs
|
||||
// the process tree via ppidOf until it lands on a pane_pid, returning that
|
||||
// pane's pane_id. ppidOf is injected so the ascent can be tested without real
|
||||
// processes. Lines that do not parse are skipped; a PID with no pane ancestor is
|
||||
// omitted.
|
||||
func resolvePaneIDsFrom(tmuxOut string, ppidOf func(int) int, claudePIDs []int) map[int]string {
|
||||
panePaneID := map[int]string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(tmuxOut), "\n") {
|
||||
f := strings.Fields(strings.TrimSpace(line))
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
pp, e := strconv.Atoi(f[0])
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
panePaneID[pp] = f[1]
|
||||
}
|
||||
|
||||
res := make(map[int]string, len(claudePIDs))
|
||||
for _, pid := range claudePIDs {
|
||||
if paneID, ok := paneAncestor(pid, panePaneID, ppidOf); ok {
|
||||
res[pid] = paneID
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// paneAncestor climbs the process tree from pid until it finds a PID that is a
|
||||
// pane_pid (a key of panePaneID), returning its pane_id. The ascent is bounded
|
||||
// (64 hops, stop at pid<=1) so a malformed /proc or a cycle cannot hang it.
|
||||
// Returns ("", false) when no ancestor is a pane. ppidOf is injected for tests.
|
||||
func paneAncestor(pid int, panePaneID map[int]string, ppidOf func(int) int) (string, bool) {
|
||||
for i := 0; pid > 1 && i < 64; i++ {
|
||||
if paneID, ok := panePaneID[pid]; ok {
|
||||
return paneID, true
|
||||
}
|
||||
pid = ppidOf(pid)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: resolve_pane_ids
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string"
|
||||
description: "Resuelve el pane_id (\"%N\") de tmux de cada proceso dado en un socket aislado (tmux -L <socket>), devolviendo un mapa claudePID -> pane_id. Lista los panes con `list-panes -a -F '#{pane_pid} #{pane_id}'` y, para cada PID, sube por el arbol de procesos (PPID en /proc/<pid>/stat) hasta dar con un pane_pid del socket; ese pane es el que aloja al proceso (normalmente pane_pid == PID porque el pane corre `exec claude`, pero un shell que lanzo el proceso como hijo se cubre con el ascenso). El pane_id es estable de por vida del pane, inmune a los swaps de window que mueve el focus de la flota, por eso es el identificador correcto de un agente frente al window_id (@N). Best-effort: socket vacio, sin PIDs o fallo de tmux -> mapa vacio; un PID sin pane resoluble se omite. Capa de control tmux de fleetview / orquestador."
|
||||
tags: [claude-fleet, infra, tmux, pane, fleet, orchestration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En la flota suele ser 'fleet'/'fleet3' ($FLEET_SOCKET). Escanea TODOS los panes del servidor de ese socket (list-panes -a). \"\" -> mapa vacio."
|
||||
- name: "claudePIDs"
|
||||
desc: "PIDs de los procesos (normalmente claude) cuyo pane_id se quiere resolver. Vacio/nil -> mapa vacio sin llamar a tmux."
|
||||
output: "map[int]string con clave = PID de entrada y valor = pane_id ('%N') del pane que lo aloja. Un PID sin pane resoluble en su ascendencia se omite (el caller lo degrada a \"\"). Mapa vacio (sin panic, sin error) si socket viene vacio, claudePIDs viene vacio, o `tmux list-panes -a` falla (socket caido, tmux ausente)."
|
||||
tested: true
|
||||
tests: ["TestResolvePaneIDsFrom", "TestResolvePaneIDsFromUnresolvable", "TestResolvePaneIDsFromMalformedLines", "TestResolvePaneIDsEmptyInputs", "TestPaneAncestorBounded"]
|
||||
test_file_path: "functions/infra/resolve_pane_ids_test.go"
|
||||
file_path: "functions/infra/resolve_pane_ids.go"
|
||||
notes: "Build tag //go:build !windows (depende de /proc y de tmux, no portable a Windows). Comparte runTmux (tmux_new_claude_window) y procPPID (tmux_map_claude_panes) con el resto de la capa tmux del paquete infra. El nucleo resolvePaneIDsFrom(tmuxOut, ppidOf, pids) es testeable inyectando la salida de tmux y el resolvedor de PPID, sin procesos reales. El ascenso por el arbol esta acotado (64 saltos, corte en pid<=1) para no colgarse ante un /proc malformado o un ciclo. Hermana de tmux_map_claude_panes_go_infra: aquella mapea PID -> window_id (@N, posicion operativa que migra con el swap); esta mapea PID -> pane_id (%N, identidad estable). Pensada para que list_claude_fleet_go_infra (y consumidores como el orquestador) identifiquen a cada agente por un handle que no baila al hacer focus."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Resolver el pane_id estable de un par de procesos claude en el socket fleet.
|
||||
byPID := infra.ResolvePaneIDs("fleet", []int{3637133, 3640001})
|
||||
for pid, paneID := range byPID {
|
||||
fmt.Printf("pid=%d -> pane=%s\n", pid, paneID) // ej. pid=3637133 -> pane=%8
|
||||
}
|
||||
// Un PID sin pane resoluble simplemente no aparece en el mapa.
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Patron tipico: cruzar la flota con su pane_id estable.
|
||||
fleet, _ := infra.ListClaudeFleet()
|
||||
pids := make([]int, 0, len(fleet))
|
||||
for _, c := range fleet {
|
||||
pids = append(pids, c.PID)
|
||||
}
|
||||
panes := infra.ResolvePaneIDs("fleet", pids) // map[pid]"%N"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un identificador ESTABLE de un agente de la flota a partir de su PID: el pane_id ("%N") de tmux no cambia durante toda la vida del pane, aunque el pane migre de window al hacer focus (break-pane + join-pane). Usala en vez de referirte al window_id (`@N`, `TmuxWindow`), que baila cada vez que el agente entra/sale de la console. La consume `list_claude_fleet_go_infra` para poblar `ClaudeFleet.PaneID`, y el orquestador para referirse a un ejecutor por un handle que no se confunde de agente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: ejecuta `tmux` y lee `/proc`.** No es determinista entre llamadas (la flota cambia). Solo lectura — no mueve ni mata panes.
|
||||
- **Best-effort, nunca crashea.** Socket vacio, lista de PIDs vacia, o un `tmux list-panes -a` que falla (socket caido, tmux no instalado) devuelven un mapa vacio. Un agente sin pane resoluble (proceso huerfano, pane cerrado) se omite del mapa; el caller lo degrada a "".
|
||||
- **Sube por el arbol de procesos.** Si el pane corre un shell que lanzo claude como hijo (en vez de `exec claude`), el pane_pid no es el claude PID; el ascenso por PPID lo cubre. El ascenso esta acotado a 64 saltos (corte en pid<=1) para no colgarse ante un ciclo o un /proc raro.
|
||||
- **Parseo del `comm` en /proc.** El PPID se saca de `/proc/<pid>/stat` tomando lo que hay tras el ULTIMO ')' (el comm va entre parentesis y puede contener espacios y ')'). Reutiliza el `procPPID` robusto del paquete.
|
||||
- **/proc + tmux no portables.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) y del binario `tmux`.
|
||||
- **Opera SIEMPRE sobre el socket aislado** (`tmux -L <socket>`), escaneando todos sus panes con `list-panes -a`. No mira el servidor tmux por defecto del usuario.
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakePPID builds a ppidOf closure from a child->parent map. Unknown PIDs map to
|
||||
// 1 (init), which terminates the ascent without matching any pane.
|
||||
func fakePPID(tree map[int]int) func(int) int {
|
||||
return func(pid int) int {
|
||||
if p, ok := tree[pid]; ok {
|
||||
return p
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFrom(t *testing.T) {
|
||||
// Two panes. Pane %3 runs claude directly (pane_pid == claude PID 100).
|
||||
// Pane %7 runs a shell (pane_pid 200) that launched claude as a child (300).
|
||||
tmuxOut := "100 %3\n200 %7\n"
|
||||
tree := map[int]int{
|
||||
300: 200, // claude (300) -> shell (200) which is the pane_pid of %7
|
||||
}
|
||||
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(tree), []int{100, 300})
|
||||
want := map[int]string{
|
||||
100: "%3", // direct: pane_pid IS the claude PID
|
||||
300: "%7", // ascent: claude's parent is the pane_pid
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("resolvePaneIDsFrom = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFromUnresolvable(t *testing.T) {
|
||||
// PID 999 has no pane in its ancestry -> omitted (caller degrades to "").
|
||||
tmuxOut := "100 %3\n"
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{999})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty map for unresolvable PID, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsFromMalformedLines(t *testing.T) {
|
||||
// Garbage / short / non-numeric lines are skipped without crashing; the one
|
||||
// valid line still resolves.
|
||||
tmuxOut := "\n \nnotapid %9\n42\n100 %3\n"
|
||||
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{100})
|
||||
want := map[int]string{100: "%3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("resolvePaneIDsFrom (malformed) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaneIDsEmptyInputs(t *testing.T) {
|
||||
// Empty socket or no PIDs -> empty map, no tmux call attempted.
|
||||
if got := ResolvePaneIDs("", []int{1, 2}); len(got) != 0 {
|
||||
t.Errorf("empty socket: expected empty map, got %v", got)
|
||||
}
|
||||
if got := ResolvePaneIDs("fleet", nil); len(got) != 0 {
|
||||
t.Errorf("nil pids: expected empty map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaneAncestorBounded(t *testing.T) {
|
||||
// A cycle in the process tree must not hang: the 64-hop bound cuts it.
|
||||
cycle := func(pid int) int {
|
||||
if pid == 500 {
|
||||
return 501
|
||||
}
|
||||
return 500 // 501 -> 500 -> 501 ... never reaches a pane
|
||||
}
|
||||
if id, ok := paneAncestor(500, map[int]string{100: "%3"}, cycle); ok {
|
||||
t.Fatalf("expected no resolution for cyclic tree, got %q", id)
|
||||
}
|
||||
}
|
||||
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")
|
||||
@@ -20,6 +20,7 @@ from .fetch_hackernews_search import fetch_hackernews_search
|
||||
from .score_demand_signal import score_demand_signal
|
||||
from .pull_gsc_search_analytics import pull_gsc_search_analytics
|
||||
from .summarize_table_duckdb import summarize_table_duckdb
|
||||
from .summarize_table_pg import summarize_table_pg
|
||||
from .describe_numeric import describe_numeric
|
||||
from .summarize_categorical import summarize_categorical
|
||||
from .infer_semantic_type import infer_semantic_type
|
||||
@@ -46,6 +47,7 @@ from .build_eda_notebook import build_eda_notebook
|
||||
|
||||
__all__ = [
|
||||
"summarize_table_duckdb",
|
||||
"summarize_table_pg",
|
||||
"spearman_corr",
|
||||
"cramers_v",
|
||||
"theils_u",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: depth_to_relief_glb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
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, 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: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image
|
||||
desc: "PIL.Image RGB usada como textura de la malla. Tipicamente la 'image' devuelta por estimate_image_depth (la imagen original)."
|
||||
- name: depth
|
||||
desc: "ndarray HxW float32 en [0,1] (1=mas cerca). Tipicamente el 'depth' devuelto por estimate_image_depth. Si ndim != 2 se devuelve status error."
|
||||
- name: out_glb_path
|
||||
desc: "Ruta de salida del .glb. El directorio padre debe existir (si no, la exportacion de trimesh falla -> status error)."
|
||||
- name: z_scale
|
||||
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."
|
||||
- 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: ""
|
||||
file_path: "python/functions/datascience/depth_to_relief_glb.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con torch + transformers + trimesh + pillow (el de apps/img_to_3d_webapp/backend/.venv).
|
||||
# Import PLANO a los modulos (el paquete datascience.__init__ arrastra deps de otros dominios).
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
from depth_to_relief_glb import depth_to_relief_glb
|
||||
|
||||
est = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg")
|
||||
assert est["status"] == "ok"
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], "/tmp/cats_relief.glb", z_scale=0.35, max_dim=220)
|
||||
print(res["status"], res["vertices"], res["faces"]) # ok 48400 96114
|
||||
print(res["glb_path"]) # /tmp/cats_relief.glb (cargable con useGLTF/GLTFLoader)
|
||||
```
|
||||
|
||||
Lanzable end-to-end (el demo CLI encadena estimate_image_depth internamente):
|
||||
|
||||
```bash
|
||||
./fn run depth_to_relief_glb_py_datascience apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
|
||||
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": ..., "faces": ..., ...}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `estimate_image_depth`, cuando quieras un modelo 3D real (no solo el mapa de profundidad):
|
||||
visualizar una foto en relieve navegable, exportar a un visor web (three.js `useGLTF`/`GLTFLoader`,
|
||||
Babylon, model-viewer) o a cualquier herramienta que lea glTF. Es el paso 2 (final) del grupo
|
||||
`img-to-3d`. Usa `max_dim` para equilibrar detalle vs peso del .glb y `z_scale` para exagerar o
|
||||
suavizar el relieve.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe el archivo .glb en `out_glb_path`. El directorio padre debe existir o
|
||||
`trimesh.export` falla (vuelve como status error, no crash).
|
||||
- **Dep**: requiere `trimesh` (4.5.x) + `pillow` + `numpy`. `trimesh` se importa dentro de la
|
||||
funcion. No esta en el venv del registry; vive en el venv de la app `img_to_3d_webapp`.
|
||||
- **No es reconstruccion real de geometria**: es un heightmap (relieve 2.5D). Solo deforma un
|
||||
plano segun la profundidad; no recupera las caras ocultas ni el volumen trasero del objeto.
|
||||
- El downsample a `max_dim` usa interpolacion bilineal sobre el depth cuantizado a uint8 (0-255)
|
||||
para reescalar; introduce una ligera perdida de precision en la profundidad de la malla.
|
||||
- UV con V invertido (`1 - v`) por convencion glTF; la textura es la imagen convertida a RGB.
|
||||
- `process=False` en Trimesh: no se hace merge de vertices ni limpieza, para preservar la
|
||||
correspondencia 1:1 vertice<->pixel (necesaria para el mapeo UV del grid).
|
||||
- **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`.
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Construcción de una malla de relieve (heightmap) texturizada exportada como glTF binario (.glb).
|
||||
|
||||
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde la
|
||||
app `img_to_3d_webapp`. A partir de un mapa de profundidad y la imagen original construye un grid
|
||||
regular de vértices cuyo eje Z es la profundidad y mapea la imagen como textura mediante
|
||||
coordenadas UV. El resultado es un modelo 3D navegable que conserva el aspecto de la imagen vista
|
||||
en relieve, cargable con useGLTF / GLTFLoader directamente.
|
||||
|
||||
Impura: escribe el archivo .glb en disco.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||
|
||||
Parámetros:
|
||||
image: PIL.Image RGB usada como textura.
|
||||
depth: ndarray HxW float32 en [0,1] (1 = más cerca de la cámara).
|
||||
out_glb_path: ruta de salida del .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,
|
||||
"height": H, "width": W}.
|
||||
Error: {"status": "error", "error": str} (depth con forma inválida, directorio de
|
||||
salida inexistente, fallo de exportación de trimesh).
|
||||
"""
|
||||
try:
|
||||
import trimesh
|
||||
|
||||
depth = np.asarray(depth, dtype=np.float32)
|
||||
if depth.ndim != 2:
|
||||
raise ValueError(f"depth debe ser un array 2D HxW, recibido ndim={depth.ndim}")
|
||||
|
||||
H, W = depth.shape
|
||||
|
||||
# Downsample para acotar el número de vértices (max_dim^2 ~ 48k vértices a 220).
|
||||
scale = max(H, W) / float(max_dim)
|
||||
if scale > 1.0:
|
||||
new_w, new_h = max(2, int(round(W / scale))), max(2, int(round(H / scale)))
|
||||
depth_img = Image.fromarray((np.clip(depth, 0, 1) * 255).astype(np.uint8))
|
||||
depth_img = depth_img.resize((new_w, new_h), Image.BILINEAR)
|
||||
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)
|
||||
ys = np.linspace(0.5, -0.5, H, dtype=np.float32)
|
||||
gx, gy = np.meshgrid(xs, ys)
|
||||
gz = (depth * z_scale).astype(np.float32)
|
||||
vertices = np.column_stack([gx.ravel(), gy.ravel(), gz.ravel()])
|
||||
|
||||
# Caras: dos triángulos por celda del grid.
|
||||
idx = np.arange(H * W, dtype=np.int64).reshape(H, W)
|
||||
v00 = idx[:-1, :-1].ravel()
|
||||
v01 = idx[:-1, 1:].ravel()
|
||||
v10 = idx[1:, :-1].ravel()
|
||||
v11 = idx[1:, 1:].ravel()
|
||||
faces = np.vstack(
|
||||
[
|
||||
np.column_stack([v00, v10, v11]),
|
||||
np.column_stack([v00, v11, v01]),
|
||||
]
|
||||
)
|
||||
|
||||
# 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)
|
||||
uu, vv = np.meshgrid(u, v)
|
||||
uv = np.column_stack([uu.ravel(), (1.0 - vv).ravel()])
|
||||
|
||||
visual = trimesh.visual.TextureVisuals(uv=uv, image=image.convert("RGB"))
|
||||
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, visual=visual, process=False)
|
||||
mesh.export(out_glb_path)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"glb_path": out_glb_path,
|
||||
"vertices": int(vertices.shape[0]),
|
||||
"faces": int(faces.shape[0]),
|
||||
"height": int(H),
|
||||
"width": int(W),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo runner end-to-end para `fn run depth_to_relief_glb_py_datascience <image_path> <out.glb>`.
|
||||
# Encadena estimate_image_depth (misma carpeta) para producir un .glb desde una imagen sin
|
||||
# tener que pasar el ndarray por CLI. La función en sí toma (image, depth); esto es solo glue
|
||||
# de demostración del flujo img→glb del grupo `img-to-3d`.
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> <out_glb_path> [z_scale] [max_dim]"}))
|
||||
sys.exit(1)
|
||||
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
|
||||
img_path = sys.argv[1]
|
||||
out_path = sys.argv[2]
|
||||
zs = float(sys.argv[3]) if len(sys.argv) > 3 else 0.35
|
||||
md = int(sys.argv[4]) if len(sys.argv) > 4 else 220
|
||||
|
||||
est = estimate_image_depth(img_path)
|
||||
if est["status"] != "ok":
|
||||
print(json.dumps(est))
|
||||
sys.exit(1)
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], out_path, z_scale=zs, max_dim=md)
|
||||
print(json.dumps(res))
|
||||
if res["status"] != "ok":
|
||||
sys.exit(1)
|
||||
@@ -18,7 +18,7 @@ LLM, parseo) devuelve {status:'error', error:str}.
|
||||
|
||||
import json
|
||||
|
||||
from core import ask_llm
|
||||
from core.ask_llm import ask_llm
|
||||
|
||||
# Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults.
|
||||
_EXPECTED_KEYS = {
|
||||
|
||||
@@ -135,7 +135,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||
"analyses": ["ventas por categoria"],
|
||||
}
|
||||
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake)
|
||||
@@ -158,7 +160,9 @@ def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
|
||||
|
||||
def test_eda_llm_insights_fills_missing_keys(monkeypatch):
|
||||
"""Si el LLM omite claves, se rellenan con defaults vacios."""
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
@@ -184,7 +188,9 @@ def test_eda_llm_insights_error_on_empty_profile():
|
||||
|
||||
|
||||
def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: ""
|
||||
@@ -194,7 +200,9 @@ def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
|
||||
|
||||
|
||||
def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch):
|
||||
import datascience.eda_llm_insights as mod
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("datascience.eda_llm_insights")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: estimate_image_depth
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def estimate_image_depth(image_path: str, model_name: str = 'depth-anything/Depth-Anything-V2-Small-hf', device: str = 'auto', use_cache: bool = True) -> dict"
|
||||
description: "Estima un mapa de profundidad monocular a partir de una sola imagen con Depth-Anything-V2 (transformers, GPU si hay). Devuelve el depth normalizado a [0,1] (1=mas cerca) y la PIL.Image original. Paso 1 del flujo img->3D (grupo img-to-3d): su salida alimenta depth_to_relief_glb."
|
||||
tags: [img-to-3d, datascience, depth, depth-estimation, monocular, transformers, depth-anything, gpu, ml, computer-vision]
|
||||
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...). Si no existe o no es imagen valida, se devuelve status error."
|
||||
- name: model_name
|
||||
desc: "Id de modelo HuggingFace de depth-estimation. Default 'depth-anything/Depth-Anything-V2-Small-hf' (rapido). Variantes: ...-Base-hf, ...-Large-hf (mas precision, mas VRAM)."
|
||||
- name: device
|
||||
desc: "'auto' (GPU0 si torch.cuda.is_available() else CPU), 'cpu', o indice/cadena cuda explicita ('cuda:0', '0'). Forma 'cuda:N' no parseable cae a GPU0; un indice entero inexistente ('99') falla en inferencia y vuelve como status error."
|
||||
- name: use_cache
|
||||
desc: "True (default) reutiliza el pipeline cacheado por (model_name, device) a nivel de proceso (evita recargar pesos en cada llamada). False construye uno nuevo y no toca la cache."
|
||||
output: "dict. Exito: {status:'ok', depth: ndarray HxW float32 en [0,1] (1=mas cerca de la camara), image: PIL.Image RGB original, height:int, width:int, model:str, device:str}. Error: {status:'error', error:str} (no lanza). El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/estimate_image_depth.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con torch + transformers + pillow (p.ej. el de apps/img_to_3d_webapp/backend/.venv).
|
||||
# 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 estimate_image_depth import estimate_image_depth
|
||||
|
||||
res = estimate_image_depth("apps/img_to_3d_webapp/samples/cats.jpg") # device='auto' -> GPU si hay
|
||||
print(res["status"]) # ok
|
||||
print(res["height"], res["width"]) # p.ej. 1024 768
|
||||
print(res["depth"].min(), res["depth"].max()) # 0.0 1.0 (normalizado)
|
||||
# res["depth"] (ndarray) + res["image"] (PIL) alimentan depth_to_relief_glb.
|
||||
```
|
||||
|
||||
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray):
|
||||
|
||||
```bash
|
||||
./fn run estimate_image_depth_py_datascience apps/img_to_3d_webapp/samples/cats.jpg
|
||||
# {"status": "ok", "height": ..., "width": ..., "depth_min": 0.0, "depth_max": 1.0, ...}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un mapa de profundidad de una imagen 2D y NO tengas sensor de profundidad:
|
||||
reconstruccion de relieve 3D (paso 1 de img->glb), efectos de paralaje, segmentacion por capas,
|
||||
ordenacion de objetos por cercania. Es el primer paso del grupo `img-to-3d`: su `depth` + `image`
|
||||
se pasan directamente a `depth_to_relief_glb_py_datascience` para generar el .glb. Para una sola
|
||||
imagen monocular; no hace SLAM, multi-vista ni metrica absoluta (la profundidad es relativa).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: carga un modelo HuggingFace (la primera vez DESCARGA pesos a `~/.cache/huggingface/`,
|
||||
cientos de MB segun la variante) y corre inferencia en GPU/CPU. Requiere red en la primera carga.
|
||||
- **Estado de proceso**: `_PIPE_CACHE` cachea el pipeline por (model_name, device) a nivel de
|
||||
modulo para no recargar pesos en cada llamada. Es estado mutable compartido del proceso. Pasa
|
||||
`use_cache=False` para construir uno aislado (no lo cachea ni lo lee). La cache persiste mientras
|
||||
viva el interprete; en un servicio de larga duracion ocupa VRAM hasta que el proceso muere.
|
||||
- **Deps pesadas**: requiere `torch`, `transformers` y `pillow` instalados. No estan en el venv del
|
||||
registry; viven en el venv de la app `img_to_3d_webapp` (torch 2.5.1+cu124). `torch`/`transformers`
|
||||
se importan dentro de la funcion, asi que el modulo se puede importar para introspeccion sin ellas.
|
||||
- **device**: 'auto' usa GPU0 si `torch.cuda.is_available()`. El resolver es tolerante con la
|
||||
forma `'cuda:N'`: si `N` no es parseable junto a 'cuda', cae a GPU0 (p.ej. `'cuda:99'` -> GPU0,
|
||||
NO error). En cambio un indice ENTERO inexistente (`'99'`) se pasa tal cual a transformers y
|
||||
falla en inferencia con `CUDA error: invalid device ordinal`, devuelto como `{status:'error'}`.
|
||||
- La profundidad es **relativa y normalizada a [0,1]**, no metrica. 1 = mas cerca de la camara
|
||||
(Depth-Anything devuelve disparidad). No comparable entre imagenes distintas.
|
||||
- Nunca lanza: errores (ruta invalida, modelo no disponible, OOM de GPU) vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
|
||||
`from estimate_image_depth import estimate_image_depth`), NO `from datascience import ...`. El
|
||||
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) que no esta en el venv
|
||||
de vision; el import del paquete fallaria por esas deps ajenas a esta funcion.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user