Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0b2dce3b0 | |||
| ff41f4f053 | |||
| f686b338d6 | |||
| 3823a28d1c | |||
| 337f75b527 | |||
| d3f05a19a5 | |||
| d7245efa59 | |||
| 1311c7e585 | |||
| db4f454f8a | |||
| f12272d002 | |||
| 495f545ec1 | |||
| f34badb500 | |||
| 3289c67986 | |||
| bcc1fe1738 | |||
| 7619347be8 | |||
| f55e41cf74 | |||
| e2e8669edf | |||
| 86d68dc9f0 | |||
| b18759823d | |||
| a59d50238d | |||
| f17d957a8f | |||
| c1f355ffa5 | |||
| 237f763c19 | |||
| bf67ff3180 | |||
| 03fc0461fa | |||
| a1105dc4c5 | |||
| 3c9e909eda | |||
| 3cf8b21fea | |||
| cbefc82c02 |
@@ -0,0 +1,112 @@
|
||||
---
|
||||
description: Modo ausente — el orquestador itera solo (lanza agentes, verifica cierres, genera tareas del roadmap, push periódico) sin supervisión, hasta que el humano vuelva. Auto-continúa con ScheduleWakeup.
|
||||
---
|
||||
|
||||
# /ausente — orquestador autónomo desatendido
|
||||
|
||||
Activa un **loop autónomo del modo orquestador**: el humano se va y tú sigues trabajando solo
|
||||
—lanzando agentes, verificando sus cierres, cerrando los que cumplen su DoD, generando tareas
|
||||
nuevas cuando la flota se vacía, y sincronizando— **hasta que el humano vuelva**. Es el modo
|
||||
orquestador (`.claude/commands/orquestador.md` + `.claude/rules/orchestration.md`) corriendo sin
|
||||
prompts humanos, con un mecanismo de auto-continuación.
|
||||
|
||||
Requisito: estar ya en modo orquestador (`role=orchestrator`). `/ausente` NO sustituye al
|
||||
orquestador, lo deja en piloto automático.
|
||||
|
||||
## Configuración de esta sesión (elegida por el humano)
|
||||
- **Al vaciarse la flota**: seguir el **roadmap ComfyUI** — generar tareas nuevas sin parar.
|
||||
- **Git**: **push periódico** — `/full-git-push` tras cada bloque de tareas cerrado.
|
||||
- **Límite**: **hasta que el humano vuelva** — heartbeat ~25 min + el watcher; tope DURO de 6
|
||||
ejecutores a la vez; parar en cuanto el humano escriba.
|
||||
|
||||
(Si se reinvoca `/ausente` en otra sesión, re-confirmar estas 3 con el humano vía AskUserQuestion.)
|
||||
|
||||
## El bucle (cada vez que te re-invocan: por FLEET-DONE del watcher o por el heartbeat)
|
||||
|
||||
1. **Drena la flota**: `./fn run drain_fleet_events`. Para cada ejecutor `DICE_TERMINADO`:
|
||||
**verifica de primera mano** (lee su report + comprueba en disco/CDP que el golden existe — no
|
||||
te fíes del autodeclarado). Si cumple el DoD → `set_dod_contract <sid> "<c>" met` y **ciérralo
|
||||
con `kill <PID>` directo** (NUNCA `kill_fleet_agent`/`kill-window`: cierra windows ajenas y se
|
||||
llevó la console de fleetview — incidente real). Si falla → nudge con el gap concreto.
|
||||
2. **Nudge** a los `ESTANCADO` (idle > 10 min con DoD sin cerrar). NUNCA a `waiting`.
|
||||
3. **¿Flota con hueco?** (< 6 ejecutores y hay backlog) → **genera la siguiente tarea del roadmap**
|
||||
(lista abajo), escribe su prompt autocontenido con aislamiento + DoD-contrato, lánzala con
|
||||
`spawn_fleet_agent --parent <tu-sid>`, fíjale nombre (`fleet_set_name`) + DoD. Respeta el tope
|
||||
de 6 y la disjunción de recursos (server/venv/GPU vs functions+fn_index vs disco — ver
|
||||
`orchestration.md`): solo UN agente dueño del server/venv a la vez; solo UNO toca
|
||||
`functions/`+`fn index` a la vez; los descargadores de modelos van a carpetas distintas.
|
||||
4. **Push periódico**: cuando cierres un bloque (>=1 tarea met e integrada), corre
|
||||
`./fn run full_git_push_bash_pipelines ""` y verifica que el padre queda alineado con
|
||||
`origin/master`. Diagnostica y reintenta si falla (regla de `/full-git-push`).
|
||||
5. **Bitácora**: añade una línea al report de bitácora `reports/NNNN-ausente-bitacora.md` (créalo
|
||||
la primera vez): timestamp + qué cerraste + qué lanzaste + push. Es lo que el humano lee al
|
||||
volver.
|
||||
6. **Reprograma el heartbeat**: `ScheduleWakeup(delaySeconds≈1500, prompt="/ausente",
|
||||
reason="loop ausente: vigilar flota + roadmap ComfyUI")`. Si hay agentes en vuelo, el watcher
|
||||
te empujará sus FLEET-DONE antes (no hace falta wakeup corto); el heartbeat es el fallback para
|
||||
cuando la flota está vacía y hay que generar tareas nuevas.
|
||||
|
||||
## Supervivencia a la compactación de contexto
|
||||
El loop es de larga duración → el contexto se llenará. **Cuando te quedes sin contexto, deja que
|
||||
el harness compacte la conversación y CONTINÚA el modo ausente** — no lo trates como una parada.
|
||||
El modo sobrevive porque su estado es **durable fuera del contexto**:
|
||||
- El `ScheduleWakeup(prompt="/ausente")` re-inyecta el modo en cada heartbeat (y el FLEET-DONE del
|
||||
watcher también te re-entra).
|
||||
- La **bitácora** `reports/ausente-bitacora-2026-06-24.md` es la memoria persistente: qué se cerró,
|
||||
qué se lanzó, qué falta del backlog, último push. **Tras una compactación, lo PRIMERO es releer
|
||||
la bitácora** (y `fleet_list`) para reconstruir el estado y seguir donde lo dejaste.
|
||||
- Mantén la bitácora al día en CADA turno (no solo al cerrar bloques) para que la compactación
|
||||
nunca pierda progreso. El comando `/ausente` + `orchestration.md` reconstruyen la doctrina.
|
||||
Una compactación NO es el humano volviendo — sigue iterando con normalidad.
|
||||
|
||||
## Parada
|
||||
- **El humano vuelve** = recibes un prompt que NO es un FLEET-DONE ni el `/ausente` del heartbeat
|
||||
(es texto del humano). Entonces: **no reprogrames el wakeup**, resume todo lo hecho durante la
|
||||
ausencia (lee la bitácora) y vuelve al modo orquestador interactivo normal.
|
||||
- Si el backlog del roadmap se agota del todo (raro): haz un último push, deja la flota cerrada,
|
||||
escribe el resumen en la bitácora, programa un heartbeat largo y queda a la espera.
|
||||
|
||||
## Reglas duras (más estrictas sin supervisión)
|
||||
- **Nada destructivo ni irreversible sin el humano**: no borrar datos/modelos/repos, no `git push
|
||||
--force`, no tocar producción/VPS, no mandar nada hacia afuera (correos, mensajes, APIs con
|
||||
efecto), no pagar/descargar gated de pago. Ante la duda, NO lo hagas: déjalo anotado en la
|
||||
bitácora como "pendiente de revisión humana".
|
||||
- **Aislamiento git por agente** SIEMPRE (sub-repo / worktree / scope disjunto). Ningún agente
|
||||
commitea el padre salvo el push periódico que corres tú.
|
||||
- **Tope 6 ejecutores**. Encola el resto.
|
||||
- **Cierre por `kill <PID>`**, jamás `pkill`/`killall`/`kill_fleet_agent` (protege la TUI/console
|
||||
de fleetview y a ti mismo).
|
||||
- **Verificación adversarial**: el golden de cada cierre se comprueba en disco/CDP/ejecución, no
|
||||
por lo que el agente diga. Honestidad en la bitácora (gaps incluidos).
|
||||
- Cada agente full-capaz sigue registry-first y delega a `fn-constructor`; tú no escribes lógica
|
||||
reutilizable inline.
|
||||
|
||||
## Backlog del roadmap ComfyUI (fuente de tareas a generar; prioriza arriba→abajo)
|
||||
Base: `reports/0064-comfyui-roadmap-plan.md` + propuestas de los reports 0069/0073/0075/0079.
|
||||
1. **Funciones 3D propuestas pendientes**: `comfyui_build_view_3d_workflow`,
|
||||
`comfyui_generate_views_from_image` (Zero123/SV3D), `comfyui_text_to_3d_oneshot` (pipeline),
|
||||
`comfyui_build_multiview_textured_3d_workflow`. (Dueño de functions/+fn index, uno a la vez.)
|
||||
2. **`comfyui_download_workflow`** (detecta Drive/GitHub/Civitai/PNG → API format) — del catálogo
|
||||
de fuentes (report `comfyui-wf-sources`).
|
||||
3. **P2 del roadmap**: `comfyui_batch_generate`, `comfyui_interrupt_queue`,
|
||||
`comfyui_ensure_server` (systemd-user con --lowvram + health).
|
||||
4. **Vídeo end-to-end**: montar workflow LTX-Video y Wan2.1 (modelos ya en /mnt/2tb), generar un
|
||||
clip corto SFW de prueba, validar VRAM 8GB; capitalizar `comfyui_build_video_workflow`.
|
||||
5. **Calidad 3D**: decimación de mesh (`fast_simplification`, gap del 0069) + watertight
|
||||
(`VoxelToMesh`); función `comfyui_simplify_mesh`.
|
||||
6. **Librería de workflows**: bajar+validar los ejemplos recomendados por `comfyui-wf-sources`,
|
||||
dejarlos en una librería local validada contra nuestro server.
|
||||
7. **Higiene**: `fn doctor` sobre las funciones nuevas (uses-functions/unused), capability page
|
||||
`docs/capabilities/comfyui.md` al día, tests de las funciones sin cobertura.
|
||||
8. Cuando ideas concretas se agoten: un agente "completeness critic" que audite el grupo `comfyui`
|
||||
y proponga el siguiente lote.
|
||||
|
||||
Cada tarea generada respeta el patrón del orquestador: prompt autocontenido (objetivo, dir,
|
||||
aislamiento, qué entrega, DoD-contrato golden+edge+error), `--parent`, nombre + DoD fijados al
|
||||
lanzar, verificación de primera mano al cerrar.
|
||||
|
||||
## Relación
|
||||
- `.claude/commands/orquestador.md` — el modo base; `/ausente` es su versión desatendida.
|
||||
- `.claude/rules/orchestration.md` — maquinaria (drain, clasificación, verificador, nudge, tope).
|
||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox). `/ausente` NO es
|
||||
eso: aquí TÚ (el orquestador interactivo) sigues conduciendo la flota de Claudes interactivos.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: "Espejo de requisitos: Claude reformula con detalle la última tarea pedida (objetivo, alcance, entregables, supuestos, criterios de aceptación, fuera de alcance y dudas) para confirmar alineación antes de ejecutar. No ejecuta nada."
|
||||
argument-hint: "[opcional: matiz o foco a tener en cuenta al reformular]"
|
||||
---
|
||||
|
||||
# /equal — confirmar alineación reformulando la tarea pedida
|
||||
|
||||
Mecanismo de **espejo de requisitos**. Cuando el usuario invoca `/equal`, NO ejecutas la tarea: devuelves tu interpretación detallada y estructurada del encargo más reciente, para que el usuario confirme o corrija antes de que empieces a trabajar.
|
||||
|
||||
El objetivo es eliminar el malentendido silencioso: prefieres gastar un turno reflejando lo que crees que se te pide que arrancar en la dirección equivocada.
|
||||
|
||||
## Qué hacer al invocarse
|
||||
|
||||
1. **Identifica la tarea más reciente que el usuario te ha pedido** en la conversación actual: la última petición de trabajo real, no el `/equal` en sí ni un comando de utilidad anterior. Si hay `$ARGUMENTS`, úsalos como matiz o foco adicional al reformular (p. ej. "céntrate en el alcance" o "asume que es solo el backend"), no como la tarea nueva.
|
||||
|
||||
2. **Reformula esa tarea de forma detallada y estructurada**, con estas secciones (omite una sección solo si es genuinamente no aplicable, no para abreviar):
|
||||
|
||||
- **Objetivo** — qué se quiere conseguir, en una o dos frases claras. El "para qué", no solo el "qué".
|
||||
- **Alcance / qué incluye** — los trozos concretos de trabajo que entiendes incluidos. Lista, no párrafo.
|
||||
- **Entregables** — qué archivos, cambios, salidas o artefactos concretos vas a producir.
|
||||
- **Supuestos** — lo que estás asumiendo por defecto al no estar dicho explícitamente (stack, ubicación, convenciones, datos, alcance temporal). Hazlos visibles para que el usuario los pueda tumbar.
|
||||
- **Criterios de aceptación** — cómo sabremos que está bien hecho. Condiciones verificables, no deseos vagos. Cuando aplique, golden + edge + caso de error (alineado con `dod_quality.md`).
|
||||
- **Fuera de alcance** — lo que NO vas a hacer, para acotar expectativas y evitar scope creep.
|
||||
- **Dudas / ambigüedades a confirmar** — preguntas concretas sobre lo que no está claro. Numéralas para que el usuario pueda responder por número. Si no hay dudas reales, dilo explícitamente ("sin dudas bloqueantes").
|
||||
|
||||
3. **Cierra pidiendo validación**: una línea clara del tipo "¿Alineado? Corrige lo que no cuadre y arranco." No empieces a trabajar hasta que el usuario confirme.
|
||||
|
||||
## Caso sin tarea previa
|
||||
|
||||
Si **no hay** una petición de trabajo clara que reformular (la conversación está vacía de encargos, o lo último fueron solo comandos de utilidad / charla), NO inventes una tarea. Dilo directamente y pide al usuario que describa qué quiere hacer, ofreciéndole la misma estructura como guía de qué información es útil (objetivo, entregables, criterios).
|
||||
|
||||
## Reglas duras
|
||||
|
||||
- **No ejecutas la tarea.** `/equal` solo refleja y pregunta. Nada de editar archivos, lanzar comandos de trabajo ni delegar a subagentes para "ir avanzando".
|
||||
- **No inventes requisitos.** Lo que no se ha pedido va a *Supuestos* (marcado como asunción) o a *Dudas* (como pregunta), nunca colado como hecho.
|
||||
- **Refleja lo que se pidió, no lo que crees que debería pedirse.** Si detectas una mejora obvia, propónla como una duda al final, no la incorpores al objetivo.
|
||||
- **Sé concreto.** Evita reformulaciones genéricas que valdrían para cualquier tarea. Cita los detalles reales del encargo (nombres de archivos, rutas, tecnologías, cifras que el usuario mencionó).
|
||||
- Mantén el comando **simple**: es un reformulador de requisitos, no un planificador de implementación ni un generador de tareas.
|
||||
|
||||
## Ejemplo de salida
|
||||
|
||||
Para una petición previa como *"añade un endpoint `/health` al servicio de deploy que devuelva 200 y la versión"*:
|
||||
|
||||
```
|
||||
## Objetivo
|
||||
Añadir un health check HTTP al deploy_server para poder verificar de forma
|
||||
programática que el servicio está vivo y saber qué versión corre.
|
||||
|
||||
## Alcance / qué incluye
|
||||
- Nuevo handler GET `/health` en el servidor HTTP de deploy_server.
|
||||
- Respuesta 200 con un cuerpo JSON que incluya la versión del binario.
|
||||
- Registro de la ruta en el router existente.
|
||||
|
||||
## Entregables
|
||||
- Cambio en el código del servidor de `apps/deploy_server/` (handler + ruta).
|
||||
- Sin migración de BD (no toca schema).
|
||||
|
||||
## Supuestos
|
||||
- La versión a devolver es la que ya expone el binario (no hay que crear un
|
||||
sistema de versionado nuevo).
|
||||
- Formato de respuesta JSON `{"status":"ok","version":"x.y.z"}`.
|
||||
- Sin autenticación en `/health` (endpoint público de liveness).
|
||||
|
||||
## Criterios de aceptación
|
||||
- Golden: `GET /health` con el servicio arriba → 200 + JSON con `version`.
|
||||
- Edge: la versión se lee correctamente aunque el binario se compile sin tag.
|
||||
- Error: si un subsistema crítico no está listo, devuelve 503, no 200 falso.
|
||||
|
||||
## Fuera de alcance
|
||||
- Readiness check de dependencias remotas (VPS, SSH).
|
||||
- Métricas / observabilidad más allá del 200.
|
||||
|
||||
## Dudas / ambigüedades a confirmar
|
||||
1. ¿`/health` debe comprobar algo real (DB, disco) o basta con responder vivo?
|
||||
2. ¿La versión sale de un ldflag de build, de un fichero, o de constante?
|
||||
3. ¿Puerto y router son los que ya usa `deploy_server serve`?
|
||||
|
||||
¿Alineado? Corrige lo que no cuadre y arranco.
|
||||
```
|
||||
|
||||
El ejemplo es ilustrativo del **formato y el nivel de detalle**; el contenido real sale siempre de la tarea concreta que el usuario haya pedido en la conversación.
|
||||
@@ -75,36 +75,59 @@ siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/or
|
||||
### 2. Lanzar cada secundario
|
||||
|
||||
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
|
||||
hay perfil fleet (`$FLEET_SOCKET`, lo normal), o kitty fuera de él. NUNCA como sub-agente del Agent
|
||||
tool (ver paso 8).** Empieza por el bloque de flota tmux cuando estás en un perfil fleet; kitty es
|
||||
el fallback para secundarios que deban vivir fuera de la flota.
|
||||
estás dentro de tmux/una flota, o kitty SOLO cuando de verdad no hay tmux. NUNCA como sub-agente del
|
||||
Agent tool (ver paso 8).** La detección de "estoy en una flota" se hace por **`$TMUX`** (señal
|
||||
fiable, vía `detect_fleet_context`), **NO por `$FLEET_SOCKET`** (a veces viene vacía en un claude
|
||||
resumido/relanzado pese a vivir en la flota → te haría caer a kitty por error). El hook
|
||||
`hook_fleet_state_inject.sh` te inyecta cada turno una línea `CONTEXTO FLEET: … socket=<X>` cuando
|
||||
estás dentro de la flota; úsala. Empieza por el bloque de flota tmux; kitty es el fallback solo fuera
|
||||
de tmux.
|
||||
|
||||
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
||||
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
||||
|
||||
#### En la flota tmux (PREFERIDO en perfil fleet)
|
||||
**Nombra cada secundario para diferenciarlo de un vistazo (regla dura).** Cuando lances varios a la
|
||||
vez, el humano tiene que poder distinguirlos rápido en el sidebar de fleetview. Dos cosas:
|
||||
|
||||
Si estás dentro de un perfil FleetView (`$FLEET_SOCKET` seteada), **NO lances kitties sueltas**:
|
||||
lanza cada ejecutor como una **window de la flota tmux** con `spawn_fleet_agent`, para que viva en
|
||||
la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
|
||||
1. **`--title` descriptivo y prefijado** en cada `spawn_fleet_agent`: un slug corto y único que diga
|
||||
QUÉ hace ese agente, idealmente con una letra/índice para ordenarlos (`A·mcp-rename`,
|
||||
`B·sql-navision`, `C·kanban`, `D·equal-skill`). Esto nombra la window tmux.
|
||||
2. **El nombre del sidebar fleetview = el campo `goal`** del `~/.claude/goals/<sid>.json`. En cuanto
|
||||
resuelvas el `sessionId` del secundario, fíjale un nombre claro con la tool
|
||||
`mcp__orchestrator__fleet_set_name` (o `./fn run set_fleet_name` cuando exista el fallback CLI) —
|
||||
mismo slug descriptivo que el `--title`. Si esa capacidad aún no está disponible en la sesión,
|
||||
apóyate solo en `--title` y en que el `goal` autogenerado del prompt sea descriptivo, pero el
|
||||
objetivo es que el sidebar liste nombres legibles, no objetivos genéricos repetidos.
|
||||
|
||||
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
|
||||
|
||||
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
|
||||
con `$FLEET_SOCKET`), **NO lances kitties sueltas**: lanza cada ejecutor como una **window de la
|
||||
flota tmux** con `spawn_fleet_agent`, para que viva en la flota, se vea en la TUI `fleetview` y sea
|
||||
conmutable con `/fleet focus`:
|
||||
|
||||
```bash
|
||||
./fn run spawn_fleet_agent --socket "$FLEET_SOCKET" --session "$FLEET_SESSION" \
|
||||
# spawn_fleet_agent auto-detecta el socket/session de $TMUX — NO hace falta pasar --socket/--session:
|
||||
./fn run spawn_fleet_agent \
|
||||
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
|
||||
--parent "$MI_SESSION_ID"
|
||||
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
|
||||
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
|
||||
```
|
||||
|
||||
- `spawn_fleet_agent_bash_infra` crea la window tmux + arranca claude con el prompt autocontenido
|
||||
(o `--skill <name>`), y con `--role executor|orchestrator` marca su `goal.json`. El aislamiento
|
||||
git (sub-repo / worktree / scope) sigue imponiéndose en el prompt.
|
||||
- `spawn_fleet_agent_bash_infra` **auto-detecta** socket/session del contexto tmux (`$TMUX`) vía
|
||||
`detect_fleet_context`; pásalos explícitos solo si quieres otra flota (los explícitos priman).
|
||||
Crea la window tmux + arranca claude con el prompt autocontenido (o `--skill <name>`), y con
|
||||
`--role executor|orchestrator` marca su `goal.json`. El aislamiento git (sub-repo / worktree /
|
||||
scope) sigue imponiéndose en el prompt.
|
||||
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
|
||||
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
|
||||
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
|
||||
retro-compatible. Ver `.claude/rules/orchestration.md`.
|
||||
|
||||
#### Fuera de la flota (kitty fallback)
|
||||
#### Fuera de tmux (kitty fallback)
|
||||
|
||||
Solo cuando `detect_fleet_context` reporta `in_tmux=false` (de verdad no hay tmux):
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
||||
@@ -113,7 +136,8 @@ la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
|
||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` lanza el secundario con el
|
||||
comando canónico (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
|
||||
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
|
||||
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo fuera de un perfil fleet.
|
||||
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo cuando NO estás en tmux
|
||||
(`$TMUX` vacía); estando en una flota, kitty fragmenta la flota — usa `spawn_fleet_agent`.
|
||||
|
||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
||||
|
||||
@@ -204,8 +228,8 @@ Cuando un secundario termina (rama pusheada + report verde):
|
||||
|
||||
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
|
||||
|
||||
1. **En perfil fleet** (`$FLEET_SOCKET`, lo normal) → `spawn_fleet_agent` (window de la flota tmux).
|
||||
2. **Fuera de un perfil fleet** → kitty con `launch_claude_agent_kitty`.
|
||||
1. **Dentro de tmux/flota** (`$TMUX` seteada — comprueba con `detect_fleet_context`, NO con `$FLEET_SOCKET`) → `spawn_fleet_agent` (auto-detecta el socket; window de la flota tmux).
|
||||
2. **Fuera de tmux** (`in_tmux=false`) → kitty con `launch_claude_agent_kitty`.
|
||||
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
|
||||
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
|
||||
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
|
||||
@@ -268,10 +292,10 @@ git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
||||
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
|
||||
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
|
||||
|
||||
# 4. Lanzar ambos (window de la flota si hay $FLEET_SOCKET; aquí kitty fallback). Tras conocer su
|
||||
# sessionId, escribe su DoD-contrato con set_dod_contract.
|
||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" ~/fn_registry/apps/kanban /tmp/orq_health.md
|
||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" /tmp/orq_capdoc /tmp/orq_capdoc.md
|
||||
# 4. Lanzar ambos como windows de la flota (estás en tmux → spawn_fleet_agent auto-detecta el socket
|
||||
# de $TMUX; kitty SOLO si in_tmux=false). Tras conocer su sessionId, escribe su DoD-contrato.
|
||||
./fn run spawn_fleet_agent --cwd ~/fn_registry/apps/kanban --prompt-file /tmp/orq_health.md --title "kanban · health endpoint" --parent "$MI_SESSION_ID"
|
||||
./fn run spawn_fleet_agent --cwd /tmp/orq_capdoc --prompt-file /tmp/orq_capdoc.md --title "fn_registry · doc deploy" --parent "$MI_SESSION_ID"
|
||||
|
||||
# 5. Seguir cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/
|
||||
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
||||
|
||||
```bash
|
||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
|
||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, pane_id ("%N", el id estable), tmux_window ("@N", interno para focus/send-keys), age, idle_seconds
|
||||
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
||||
```
|
||||
|
||||
@@ -58,7 +58,7 @@ devuelven salida estructurada y se registran en la telemetría como cualquier MC
|
||||
|
||||
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
||||
|---|---|---|
|
||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, **pane_id** (el id estable), age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
||||
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
||||
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
||||
@@ -69,6 +69,15 @@ Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directa
|
||||
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
||||
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
||||
|
||||
**Identifica a cada agente por su `pane_id` ("%N").** Es el id ESTABLE de por vida del pane: el
|
||||
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
|
||||
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
|
||||
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
|
||||
necesitan la window viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
|
||||
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada);
|
||||
para el nudge, lee `tmux_window` del binario `fleetview list --json` (que sí lo conserva como campo
|
||||
interno), nunca del payload del MCP.
|
||||
|
||||
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
@@ -123,6 +132,21 @@ existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo)
|
||||
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
|
||||
sobre el feed del watcher.
|
||||
|
||||
Además, el mismo hook inyecta una línea **`CONTEXTO FLEET`** cuando detecta (vía
|
||||
`detect_fleet_context_bash_infra`, leyendo **`$TMUX`**, no `$FLEET_SOCKET`) que el orquestador vive
|
||||
dentro de una flota tmux:
|
||||
|
||||
```
|
||||
CONTEXTO FLEET: estás dentro de la fleet tmux socket=<X> session=<Y>. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aquí.
|
||||
```
|
||||
|
||||
Es el recordatorio que evita el bug de caer a kitty cuando `$FLEET_SOCKET` viene vacía pese a estar
|
||||
en la flota: la detección de contexto se hace por `$TMUX` (señal fiable que todo proceso dentro de
|
||||
tmux tiene siempre), no por `$FLEET_SOCKET` (a veces ausente en un claude resumido/relanzado). Esta
|
||||
parte del hook no necesita venv ni python (solo bash + tmux) y se emite antes del bloque
|
||||
`FLEET-STATE`; si el detector falta o `$TMUX` está vacía, simplemente no se emite la línea (turno
|
||||
intacto).
|
||||
|
||||
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
|
||||
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
|
||||
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
|
||||
@@ -302,7 +326,8 @@ en lote.
|
||||
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
|
||||
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
|
||||
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
|
||||
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty cuando hay perfil fleet. `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
|
||||
| `detect_fleet_context_bash_infra` | Detectar si estás en una flota tmux derivando socket/session de `$TMUX` (señal fiable), con fallback a `$FLEET_SOCKET`. Devuelve JSON `{in_fleet,in_tmux,socket,session,source}`. Lo usan `spawn_fleet_agent` (auto-detección de socket) y el hook (línea `CONTEXTO FLEET`) para no caer a kitty estando en la flota |
|
||||
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty siempre que estés en tmux. **Auto-detecta socket/session de `$TMUX`** (vía `detect_fleet_context`) si no se pasan `--socket`/`--session` (los explícitos priman). `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
|
||||
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
||||
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
||||
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
||||
@@ -311,7 +336,7 @@ en lote.
|
||||
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
||||
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
|
||||
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
|
||||
`spawn_fleet_agent`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
|
||||
`spawn_fleet_agent`, `detect_fleet_context`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
|
||||
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
|
||||
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
|
||||
mano).
|
||||
|
||||
@@ -46,6 +46,24 @@ ROLE=""
|
||||
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
|
||||
|
||||
# Contexto de flota: recordarle al orquestador en que socket/sesion tmux vive,
|
||||
# para que lance ejecutores con spawn_fleet_agent (auto-detecta el socket) y
|
||||
# NUNCA caiga a kitty estando dentro de la flota. La deteccion va por $TMUX
|
||||
# (senal fiable), no por $FLEET_SOCKET (a veces vacia en un claude resumido/
|
||||
# relanzado). No necesita venv ni python: solo bash + tmux. Degrada limpio: si
|
||||
# el detector falta o falla, simplemente no se emite la linea (turno intacto).
|
||||
DETECTOR="$PROJECT_DIR/bash/functions/infra/detect_fleet_context.sh"
|
||||
if [ -f "$DETECTOR" ]; then
|
||||
CTX=$(bash "$DETECTOR" 2>/dev/null || true)
|
||||
IN_FLEET=$(printf '%s' "$CTX" | sed -n 's/.*"in_fleet":\(true\|false\).*/\1/p')
|
||||
F_SOCKET=$(printf '%s' "$CTX" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')
|
||||
F_SESSION=$(printf '%s' "$CTX" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')
|
||||
if [ "$IN_FLEET" = "true" ]; then
|
||||
printf 'CONTEXTO FLEET: estas dentro de la fleet tmux socket=%s session=%s. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aqui.\n' "$F_SOCKET" "$F_SESSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
PY="$PROJECT_DIR/python/.venv/bin/python3"
|
||||
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"registry",
|
||||
"jupyter"
|
||||
"jupyter",
|
||||
"orchestrator"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -37,6 +37,7 @@ python/.venv/
|
||||
|
||||
# Externalized apps and analysis (each is its own Gitea repo)
|
||||
apps/*/
|
||||
cpp/apps/*/
|
||||
analysis/*/
|
||||
|
||||
# Projects (each is its own git repo, only project.md templates are versioned)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: detect_fleet_context
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
signature: "detect_fleet_context() -> JSON {in_fleet,in_tmux,socket,session,source}"
|
||||
description: "Detecta de forma robusta si el proceso corre dentro de una flota tmux FleetView, derivando socket y sesion de $TMUX (senal fiable) en vez de $FLEET_SOCKET (fragil, a veces vacia en un claude resumido/relanzado). Salida JSON con in_fleet/in_tmux/socket/session/source."
|
||||
tags: [orchestration, fleet, tmux, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
tested: false
|
||||
file_path: "bash/functions/infra/detect_fleet_context.sh"
|
||||
params:
|
||||
- name: "(ninguno)"
|
||||
desc: "No recibe argumentos. Lee el entorno ($TMUX, con fallback a $FLEET_SOCKET/$FLEET_SESSION) y consulta el servidor tmux."
|
||||
output: "JSON en stdout: {\"in_fleet\":bool, \"in_tmux\":bool, \"socket\":str, \"session\":str, \"source\":\"tmux|fleet_socket|none\"}. in_tmux=true basta para lanzar una window; in_fleet es la senal semantica de 'estoy en una flota'."
|
||||
---
|
||||
|
||||
# detect_fleet_context
|
||||
|
||||
Detecta el contexto de flota del proceso actual sin depender de `$FLEET_SOCKET`.
|
||||
|
||||
## Por que existe
|
||||
|
||||
La deteccion de "estoy en una flota FleetView" dependia de la variable de
|
||||
entorno `$FLEET_SOCKET`, que `launch_fleetclaude` exporta con
|
||||
`tmux set-environment -g`. Esa variable solo llega a los procesos que tmux
|
||||
arranca **despues** de setearla: un `claude` relanzado o resumido a mano puede
|
||||
no heredarla y `$FLEET_SOCKET` queda vacia, aunque ese claude SI viva en una
|
||||
window de la flota. Cuando eso pasa, el modo orquestador cae al fallback kitty
|
||||
(`launch_claude_agent_kitty`) y lanza ejecutores en terminales sueltas en vez de
|
||||
como windows de la flota.
|
||||
|
||||
La senal **fiable** es `$TMUX`: todo proceso dentro de tmux la tiene SIEMPRE, con
|
||||
el formato `/tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>`. De ahi se extrae
|
||||
el socket (basename del path antes de la primera coma) y, con
|
||||
`tmux -L <socket> display-message -p '#{session_name}'`, la sesion actual.
|
||||
|
||||
## Salida
|
||||
|
||||
```json
|
||||
{"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
|
||||
```
|
||||
|
||||
| Campo | Significado |
|
||||
|---|---|
|
||||
| `in_fleet` | Heuristica de "estoy en una flota". `true` si en tmux Y (socket/sesion casan `fleet`, O hay window `fleetview`, O la sesion tiene >= 2 windows). |
|
||||
| `in_tmux` | `true` si el proceso esta dentro de tmux. Basta para lanzar una window (mejor que caer a kitty). |
|
||||
| `socket` | Socket tmux derivado de `$TMUX` (o de `$FLEET_SOCKET` en fallback). |
|
||||
| `session` | Sesion tmux actual resuelta con `display-message` (fallback a `$FLEET_SESSION` o al socket). |
|
||||
| `source` | `tmux` (derivado de `$TMUX`), `fleet_socket` (fallback), o `none`. |
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dentro de una window de la flota fleet3:
|
||||
bash bash/functions/infra/detect_fleet_context.sh
|
||||
# {"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
|
||||
|
||||
# Fuera de tmux, sin FLEET_SOCKET:
|
||||
env -u TMUX -u FLEET_SOCKET bash bash/functions/infra/detect_fleet_context.sh
|
||||
# {"in_fleet":false,"in_tmux":false,"socket":"","session":"","source":"none"}
|
||||
|
||||
# Parsear el socket con jq para pasarlo a spawn_fleet_agent:
|
||||
ctx=$(bash bash/functions/infra/detect_fleet_context.sh)
|
||||
sock=$(printf '%s' "$ctx" | jq -r .socket)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de lanzar un ejecutor de la flota: llama a esta funcion para saber si
|
||||
estas dentro de una flota tmux. Si `in_tmux=true`, lanza con `spawn_fleet_agent`
|
||||
(que ya la usa para auto-detectar el socket); NUNCA caigas a kitty. Tambien la
|
||||
usa el hook `hook_fleet_state_inject.sh` para recordarle al orquestador el socket
|
||||
de su flota cada turno.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es **impura**: consulta el servidor tmux (`display-message`, `list-windows`).
|
||||
No modifica estado.
|
||||
- `in_fleet` es **heuristico** a proposito. Para LANZAR basta `in_tmux=true`
|
||||
(lanzar una window en cualquier tmux supera a una kitty suelta). `in_fleet` es
|
||||
solo la senal semantica que consume el hook y la doctrina.
|
||||
- Fallback `source=fleet_socket`: si `$TMUX` no esta pero `$FLEET_SOCKET` si,
|
||||
devuelve `socket`/`session` de esas vars con `in_tmux=false`. Un
|
||||
`tmux -L <socket> new-window` puede seguir funcionando si el servidor existe,
|
||||
aunque el caller no este attached.
|
||||
- No requiere `jq` ni python: emite el JSON con `printf`, para poder ser el
|
||||
detector base que invocan hooks y otras funciones bash.
|
||||
- Si `tmux` no esta instalado y `$TMUX` esta seteada (raro), `socket` se deriva
|
||||
igual de `$TMUX` pero `session` cae al fallback y `in_fleet` no se puede afinar
|
||||
por windows.
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# detect_fleet_context — detecta de forma robusta si el proceso actual corre
|
||||
# dentro de una sesion tmux de una flota FleetView, derivando el socket y la
|
||||
# sesion de la variable de entorno $TMUX (senal fiable) en vez de depender de
|
||||
# $FLEET_SOCKET (que a veces viene vacia en el entorno de un claude resumido o
|
||||
# relanzado, aunque ese claude SI viva en una window de la flota).
|
||||
#
|
||||
# Por que $TMUX y no $FLEET_SOCKET:
|
||||
# launch_fleetclaude exporta FLEET_SOCKET/FLEET_SESSION con `tmux
|
||||
# set-environment -g`. Esa variable solo llega a los procesos que tmux arranca
|
||||
# DESPUES de setearla; un claude relanzado o resumido a mano puede no heredarla
|
||||
# y entonces $FLEET_SOCKET queda vacia. En cambio, todo proceso que corre
|
||||
# dentro de tmux tiene SIEMPRE $TMUX seteada, con el formato:
|
||||
# /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
|
||||
# De ahi se extrae el socket (basename del path antes de la primera coma) y,
|
||||
# con `tmux -L <socket> display-message -p '#{session_name}'`, la sesion
|
||||
# actual. Eso identifica el contexto fleet sin depender de $FLEET_SOCKET.
|
||||
#
|
||||
# Salida: JSON en stdout con los campos:
|
||||
# in_fleet : true|false — heuristica de "estoy en una flota" (ver criterio).
|
||||
# in_tmux : true|false — estoy dentro de tmux (basta para lanzar una window).
|
||||
# socket : nombre del socket tmux derivado ("" si no hay).
|
||||
# session : nombre de la sesion tmux actual ("" si no se resuelve).
|
||||
# source : "tmux" | "fleet_socket" | "none" — de donde se derivo el contexto.
|
||||
#
|
||||
# Criterio de "flota reconocible" (in_fleet): estar en tmux (in_tmux) Y que se
|
||||
# cumpla al menos uno, de mas fiable a menos:
|
||||
# 1. el socket o la sesion casan el patron de flota (contienen "fleet"), o
|
||||
# 2. existe una window llamada "fleetview" (la TUI de la flota), o
|
||||
# 3. la sesion tiene >= 2 windows (una flota agrupa varios agentes en windows).
|
||||
# Es heuristico a proposito: para LANZAR un ejecutor basta con in_tmux (lanzar
|
||||
# una window en cualquier tmux es mejor que caer a una kitty suelta); in_fleet es
|
||||
# la senal semantica que consume el hook del orquestador y la doctrina.
|
||||
#
|
||||
# Funcion IMPURA: lee el entorno y consulta el servidor tmux (display-message,
|
||||
# list-windows). No modifica estado. Degrada limpio: si tmux no esta o falla
|
||||
# cualquier consulta, devuelve los campos que pueda y nunca aborta con error.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
detect_fleet_context() {
|
||||
local socket="" session="" source="none"
|
||||
local in_tmux="false" in_fleet="false"
|
||||
|
||||
if [[ -n "${TMUX:-}" ]]; then
|
||||
in_tmux="true"
|
||||
source="tmux"
|
||||
# $TMUX = /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
|
||||
# Socket = basename del path antes de la primera coma.
|
||||
local tmux_path="${TMUX%%,*}"
|
||||
socket="$(basename "$tmux_path" 2>/dev/null || true)"
|
||||
# Sesion actual: tmux resuelve el cliente via $TMUX. -L fija el socket.
|
||||
if command -v tmux >/dev/null 2>&1 && [[ -n "$socket" ]]; then
|
||||
session="$(tmux -L "$socket" display-message -p '#{session_name}' 2>/dev/null || true)"
|
||||
fi
|
||||
# Fallback de sesion si display-message no resolvio nada.
|
||||
[[ -z "$session" ]] && session="${FLEET_SESSION:-$socket}"
|
||||
elif [[ -n "${FLEET_SOCKET:-}" ]]; then
|
||||
# No estamos en tmux pero hay FLEET_SOCKET exportada: usarla como ultimo
|
||||
# recurso (un claude que perdio $TMUX pero conserva la env del perfil).
|
||||
in_tmux="false"
|
||||
source="fleet_socket"
|
||||
socket="${FLEET_SOCKET}"
|
||||
session="${FLEET_SESSION:-$socket}"
|
||||
fi
|
||||
|
||||
# Heuristica in_fleet: solo tiene sentido si estamos en tmux.
|
||||
if [[ "$in_tmux" == "true" && -n "$socket" ]]; then
|
||||
local sl="${socket,,}" sesl="${session,,}"
|
||||
if [[ "$sl" == *fleet* || "$sesl" == *fleet* ]]; then
|
||||
in_fleet="true"
|
||||
elif command -v tmux >/dev/null 2>&1; then
|
||||
# Construir el target de sesion sin trucos de expansion fragiles.
|
||||
local -a tgt=()
|
||||
[[ -n "$session" ]] && tgt=(-t "$session")
|
||||
# window "fleetview" presente => flota.
|
||||
if tmux -L "$socket" list-windows "${tgt[@]}" \
|
||||
-F '#{window_name}' 2>/dev/null | grep -qx 'fleetview'; then
|
||||
in_fleet="true"
|
||||
else
|
||||
# >= 2 windows => agrupacion tipo flota.
|
||||
local nwin
|
||||
nwin="$(tmux -L "$socket" list-windows "${tgt[@]}" \
|
||||
-F x 2>/dev/null | wc -l | tr -d ' ')"
|
||||
[[ "${nwin:-0}" -ge 2 ]] && in_fleet="true"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# JSON sin dependencias (jq/python no requeridos: este es el detector base).
|
||||
printf '{"in_fleet":%s,"in_tmux":%s,"socket":"%s","session":"%s","source":"%s"}\n' \
|
||||
"$in_fleet" "$in_tmux" "$socket" "$session" "$source"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
detect_fleet_context "$@"
|
||||
fi
|
||||
@@ -3,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"
|
||||
|
||||
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from ab38127ac0
@@ -114,6 +114,24 @@ static LRESULT CALLBACK fn_subclass_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPAR
|
||||
case WM_EXITSIZEMOVE:
|
||||
g_in_sizemove.store(false, std::memory_order_release);
|
||||
break;
|
||||
case WM_SYSKEYDOWN:
|
||||
// Alt+Enter would otherwise toggle a borderless-fullscreen mode
|
||||
// (driven by some GPU drivers' OpenGL/Vulkan hotkey, or by
|
||||
// DefWindowProc on certain window styles). We never want that:
|
||||
// these are docked tool windows, not games. Consume the keystroke
|
||||
// so the window stays in its normal decorated state. Every other
|
||||
// Alt+key combo chains through to GLFW/DefWindowProc untouched.
|
||||
if (wp == VK_RETURN) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_SYSCHAR:
|
||||
// Swallow the system "ding" beep that the suppressed Alt+Enter
|
||||
// above would otherwise trigger via the default char handler.
|
||||
if (wp == VK_RETURN) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONDOWN:
|
||||
// Alt + LMB anywhere on the window initiates a native modal MOVE
|
||||
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
|
||||
|
||||
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||
@@ -57,6 +57,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
|
||||
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
|
||||
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
|
||||
| [sql-connect](sql-connect.md) | 3 | Conexion directa y consulta a Microsoft SQL Server (Navision) via pymssql: abrir conexion (login_timeout), SELECT parametrizada con binding seguro -> {columns, rows, row_count}, y pipeline one-shot run_mssql_query (CLI JSON/CSV). Elimina el copia-pega manual de CSV de Navision. Credenciales desde pass, host = IP LAN de Windows desde WSL2 |
|
||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||
@@ -68,6 +69,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# ComfyUI — Generación de imágenes por API HTTP y por la UI (CDP)
|
||||
|
||||
Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
(motor de Stable Diffusion basado en grafos de nodos) de dos formas complementarias:
|
||||
|
||||
- **Por su API HTTP** (`/prompt`, `/history`, `/object_info`): construir un workflow en
|
||||
"API format", encolarlo, esperar el resultado. Headless, scriptable, sin navegador.
|
||||
- **Por su UI web vía CDP**: operar la pestaña de ComfyUI ya abierta en el navegador diario
|
||||
(cargar un workflow en el grafo visual, editar widgets en vivo, encolar como si pulsaras
|
||||
"Queue Prompt", exportar el grafo, refrescar combos). Lo que el usuario ve, el agente lo
|
||||
toca. Todas las funciones de UI componen la primitiva de transport
|
||||
[`cdp_eval_py_browser`](../../python/functions/browser/cdp_eval.md) — no reinventan CDP.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
||||
|
||||
## Dos caminos, mismo motor
|
||||
|
||||
```
|
||||
API HTTP (dominio ml) UI web vía CDP (dominio browser)
|
||||
────────────────────── ───────────────────────────────
|
||||
build_txt2img_workflow (dict API format) load_workflow_ui (dict -> grafo visual)
|
||||
│ set_node_widget_ui (tuning en vivo)
|
||||
▼ queue_prompt_ui (= botón Queue Prompt)
|
||||
submit_workflow (POST /prompt -> id) export_workflow_ui (grafo -> dict API format)
|
||||
▼ refresh_nodes_ui (recarga combos)
|
||||
wait_result (poll /history -> PNG)
|
||||
object_info (catálogo de nodos) download_model (dominio ml) -> baja checkpoints
|
||||
```
|
||||
|
||||
El **API format** (dict de nodos numerados que produce `build_txt2img_workflow` y consume
|
||||
`submit_workflow`) es el puente entre ambos mundos: `load_workflow_ui` lo carga en la UI y
|
||||
`export_workflow_ui` lo recupera de la UI, así que puedes mezclar libremente API y navegador.
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
### Por API HTTP — dominio `ml`
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
||||
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
||||
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
||||
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
||||
| [comfyui_download_model_py_ml](../../python/functions/ml/comfyui_download_model.md) | `download_model(url, dest_subdir='checkpoints', *, comfyui_dir, filename, token, overwrite, timeout_s) -> dict` | Descarga un checkpoint/LoRA/VAE a `models/<dest_subdir>/`. Soporta Civitai (token) y HuggingFace. Valida que no sea HTML de error ni `.safetensors` corrupto. Impura. |
|
||||
| [comfyui_interrupt_queue_py_ml](../../python/functions/ml/comfyui_interrupt_queue.md) | `interrupt_queue(server='127.0.0.1:8188') -> dict` | Corta la generación en curso (POST `/interrupt`) y lee la cola (GET `/queue`) → `{ok, interrupted, queue_running, queue_pending, error}`. Freno de mano; degrada limpio en fallo de red. Impura. |
|
||||
| [comfyui_batch_generate_py_ml](../../python/functions/ml/comfyui_batch_generate.md) | `batch_generate(workflow, *, seeds=None, server='127.0.0.1:8188') -> dict` | Encola N variantes (una por seed), parcheando el campo de semilla de los nodos sampler sin mutar el original → `{ok, prompt_ids, count, error}`. Re-roll en una llamada. Compone `submit_workflow`. Impura. |
|
||||
| [comfyui_queue_manage_py_ml](../../python/functions/ml/comfyui_queue_manage.md) | `queue_manage(action, *, server='127.0.0.1:8188', prompt_id=None) -> dict` | API de cola completa que complementa a `interrupt_queue`: `action='status'` (GET `/queue`), `'clear'` (vacía pendientes), `'delete'` (borra un prompt, requiere `prompt_id`), `'history'` (cuenta `/history`) → `{ok, action, queue_running, queue_pending, history_count, error}`. Degrada limpio en fallo de red. Impura. |
|
||||
| [comfyui_stream_progress_py_ml](../../python/functions/ml/comfyui_stream_progress.md) | `stream_progress(prompt_id, *, server='127.0.0.1:8188', client_id=None, timeout=300) -> dict` | Progreso en vivo por WebSocket `/ws` (alternativa a `wait_result`): cuenta pasos del sampler (`steps_seen`), último nodo, y detecta el fin → `{ok, completed, steps_seen, last_node, method, error}`. Para ver progreso comparte el `client_id` con el submit. Cae a polling si falta `websocket-client`. Impura. |
|
||||
|
||||
### Builders, validación e import — dominio `ml` (P0, issue 0064)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_img2img_workflow_py_ml](../../python/functions/ml/comfyui_build_img2img_workflow.md) | `build_img2img_workflow(ckpt_name, init_image, positive, negative='', *, denoise=0.6, steps, cfg, seed, ...) -> dict` | Builder img2img (Checkpoint + LoadImage → VAEEncode → KSampler con `denoise` → VAEDecode → SaveImage). **Pura**. |
|
||||
| [comfyui_build_upscale_workflow_py_ml](../../python/functions/ml/comfyui_build_upscale_workflow.md) | `build_upscale_workflow(image, *, model_name='4x-UltraSharp.pth', method='model') -> dict` | Builder upscale: `method='model'` (ESRGAN: UpscaleModelLoader + ImageUpscaleWithModel) o `method='latent'` (ImageScaleBy x2 sin modelo). **Pura**. |
|
||||
| [comfyui_inject_lora_py_ml](../../python/functions/ml/comfyui_inject_lora.md) | `inject_lora(workflow, lora_name, *, strength_model=1.0, strength_clip=1.0, model_node=None, clip_node=None) -> dict` | Inserta un LoraLoader en un workflow ya construido, reconectando model/clip de la fuente a sus consumidores. Encadenable. **Pura** (no muta la entrada). |
|
||||
| [comfyui_validate_workflow_py_ml](../../python/functions/ml/comfyui_validate_workflow.md) | `validate_workflow(workflow, server='127.0.0.1:8188', timeout) -> dict` | Cruza class_type y nombres de modelo contra `/object_info`; devuelve `{valid, missing_nodes, missing_models}` ANTES de encolar. Compone `object_info`. Impura. |
|
||||
| [comfyui_import_workflow_json_py_ml](../../python/functions/ml/comfyui_import_workflow_json.md) | `import_workflow_json(source, *, server, timeout) -> dict` | Lee un workflow JSON de URL o path local; normaliza UI graph → API format (widgets vía `object_info`); passthrough si ya es API. Impura. |
|
||||
| [comfyui_import_workflow_png_py_ml](../../python/functions/ml/comfyui_import_workflow_png.md) | `import_workflow_png(png_path_or_url, *, timeout) -> dict` | Extrae el workflow embebido en los chunks `prompt` (API) / `workflow` (UI) de un PNG de ComfyUI (tEXt/zTXt/iTXt, stdlib). Path o URL. Impura. |
|
||||
| [comfyui_download_workflow_py_ml](../../python/functions/ml/comfyui_download_workflow.md) | `download_workflow(source, dest=None, *, server, civitai_token, hf_token, timeout) -> dict` | **Dispatcher**: descarga un workflow de CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Detecta el tipo por la URL y delega; tras bajar compone `import_workflow_json`/`import_workflow_png`. Catálogo de fuentes: `reports/0080`. Impura. |
|
||||
| [comfyui_read_png_metadata_py_ml](../../python/functions/ml/comfyui_read_png_metadata.md) | `read_png_metadata(png_path) -> dict` | Lee los parámetros de generación (modelo, seed, steps, cfg, sampler, prompts) de un PNG generado por ComfyUI. Impura (I/O disco). |
|
||||
| [comfyui_fetch_output_image_py_ml](../../python/functions/ml/comfyui_fetch_output_image.md) | `fetch_output_image(filename, *, subfolder='', type_='output', server, dest_dir='.', timeout) -> dict` | Descarga el PNG generado vía GET `/view` a disco local (`wait_result` solo da metadata). Impura. |
|
||||
|
||||
### Potencia y assets de internet — dominio `ml` (P1, issue 0064)
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_inpaint_workflow_py_ml](../../python/functions/ml/comfyui_build_inpaint_workflow.md) | `build_inpaint_workflow(ckpt_name, image, mask, positive, negative='', *, denoise=1.0, steps, cfg, seed, ...) -> dict` | Builder inpaint: CheckpointLoaderSimple + LoadImage + LoadImageMask → VAEEncodeForInpaint → KSampler → VAEDecode → SaveImage. Regenera solo la zona enmascarada. **Pura**. |
|
||||
| [comfyui_build_controlnet_workflow_py_ml](../../python/functions/ml/comfyui_build_controlnet_workflow.md) | `build_controlnet_workflow(ckpt_name, control_image, cn_name, positive, negative='', *, strength=1.0, steps, cfg, seed, width, height) -> dict` | Builder ControlNet: ControlNetLoader + ControlNetApply inyectan el mapa de control sobre el condicionamiento positivo. **Pura**. |
|
||||
| [comfyui_build_sdxl_refiner_workflow_py_ml](../../python/functions/ml/comfyui_build_sdxl_refiner_workflow.md) | `build_sdxl_refiner_workflow(base_ckpt, refiner_ckpt, positive, negative='', *, base_steps=20, refiner_steps=5, cfg, seed, width=1024, height=1024) -> dict` | SDXL base+refiner: dos KSamplerAdvanced encadenados (base con `return_with_leftover_noise`, refiner termina). **Pura**. |
|
||||
| [comfyui_search_civitai_models_py_ml](../../python/functions/ml/comfyui_search_civitai_models.md) | `search_civitai_models(query, *, types='Checkpoint', base_model=None, sort, limit=20, token=None) -> dict` | Busca modelos/LoRAs en la API pública de Civitai → `{ok, items:[{name, type, base_model, version_id, download_url, nsfw}], count, error}`. Sin token funciona. Impura. |
|
||||
| [comfyui_install_custom_node_py_ml](../../python/functions/ml/comfyui_install_custom_node.md) | `install_custom_node(repo_url, *, comfyui_dir, pip_install=True, restart=False) -> dict` | git clone en `custom_nodes/` + pip/uv install de requirements en el venv de ComfyUI. NO reinicia el server (restart=False). Impura. |
|
||||
| [comfyui_resolve_workflow_deps_py_ml](../../python/functions/ml/comfyui_resolve_workflow_deps.md) | `resolve_workflow_deps(workflow, server='127.0.0.1:8188') -> dict` | Para un workflow ajeno: valida y traduce lo que falta en acciones (`{missing_nodes, missing_models, suggestions}`). Compone `validate_workflow`. Impura. |
|
||||
| [comfyui_list_installed_models_py_ml](../../python/functions/ml/comfyui_list_installed_models.md) | `list_installed_models(folder=None, comfyui_dir='~/ComfyUI') -> dict` | Lista modelos por carpeta resolviendo la ruta real de `extra_model_paths.yaml` (`/mnt/2tb/comfyui_models/`) + la nativa. Escaneo de FS, no depende del server. Impura. |
|
||||
|
||||
### Retoque pro y oneshot — dominio `ml` + `pipelines` (P0, lote report 0093)
|
||||
|
||||
Builders que envuelven custom-nodes "pro" ya instalados (Impact-Pack, UltimateSDUpscale) y la
|
||||
promoción del flujo txt2img a una sola llamada. Los class_types se verificaron contra el
|
||||
`/object_info` del server vivo (FaceDetailer, UltralyticsDetectorProvider, UltimateSDUpscale).
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_facedetailer_workflow_py_ml](../../python/functions/ml/comfyui_build_facedetailer_workflow.md) | `build_facedetailer_workflow(base_workflow_or_image, ckpt_name, positive, negative='', *, bbox_model='face_yolov8m.pt', denoise=0.5, ...) -> dict` | Builder **FaceDetailer** (Impact-Pack): detecta caras con `UltralyticsDetectorProvider` (YOLO bbox) y las regenera para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen en `input/` (str) o un workflow base (dict): toma la imagen del `VAEDecode` y reutiliza el `CheckpointLoaderSimple`. No usa SAM (no instalado). **Pura**. |
|
||||
| [comfyui_build_hires_fix_workflow_py_ml](../../python/functions/ml/comfyui_build_hires_fix_workflow.md) | `build_hires_fix_workflow(ckpt_name, positive, negative='', *, first_pass=(768,768), upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Builder **hires fix** de 2 pasadas: genera base (KSampler) y la amplía re-difundiéndola por tiles con `UltimateSDUpscale` + Remacri (`denoise<1` = añade detalle real). Distinto de `build_upscale_workflow` (ESRGAN puro, sin re-difusión). **Pura**. |
|
||||
| [comfyui_txt2img_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_txt2img_oneshot.md) | `txt2img_oneshot(prompt, *, ckpt='dreamshaper_8.safetensors', negative='', server, dest=None, wait_timeout, **gen) -> dict` | **Pipeline** texto → PNG en disco en una llamada: build_txt2img + submit + wait + fetch_output_image → `{ok, image_path, prompt_id, error}`. Promoción de la secuencia (issue 0087). Impuro. |
|
||||
| [comfyui_build_grid_py_ml](../../python/functions/ml/comfyui_build_grid.md) | `build_grid(image_paths, *, cols=None, cell=512, out_path=None, labels=None) -> dict` | Monta un **grid / contact-sheet** PIL de N imágenes para comparar de un vistazo (p.ej. el output de `batch_generate` con varios seeds). Celdas que conservan aspect ratio, rejilla casi cuadrada por defecto, rótulos opcionales → `{ok, out_path, rows, cols, error}`. Post-proceso local de imagen (no toca el server). Impura (I/O disco, PIL). |
|
||||
|
||||
### Vídeo (txt2video) — dominio `ml` (tag `video-generation`)
|
||||
|
||||
ComfyUI ≥ 0.26.0 trae soporte nativo para **vídeo por difusión**. `build_video_workflow` cubre
|
||||
los dos modelos que caben en 8 GB: **LTX-Video 2B v0.9.5** (`model='ltx'`, checkpoint todo-en-uno +
|
||||
VAE temporal + scheduler propio — validado end-to-end en `reports/0084`, clip real de 65 frames,
|
||||
pico ~7.7 GB) y **Wan2.1 T2V 1.3B** (`model='wan'`, diffusion + umt5 + vae aparte — plantilla nativa
|
||||
canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
|
||||
|
||||
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
|
||||
|
||||
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
|
||||
reconstruye en una malla 3D GLB con un grafo de 9 nodos (`LoadImage → ImageOnlyCheckpointLoader
|
||||
→ CLIPVisionEncode → Hunyuan3Dv2Conditioning → EmptyLatentHunyuan3Dv2 → KSampler →
|
||||
VAEDecodeHunyuan3D → VoxelToMeshBasic → SaveGLB`). El checkpoint es self-contained (DiT de forma +
|
||||
VAE 3D + encoder de imagen en un `.safetensors`). Salida **shape-only** (sin color/textura). Detalle
|
||||
y benchmark en `reports/0069-2026-06-23-comfyui-img-to-3d.md`. Para mejorar la cara trasera/laterales,
|
||||
genera vistas novel-view desde 1 imagen (`generate_views_from_image`, reports `0073`); para VER el GLB
|
||||
resultante interactivo dentro de un nodo de la UI, monta el visor `Load3D` (`build_view_3d_workflow`,
|
||||
report `0079`).
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_build_image_to_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_image_to_3d_workflow.md) | `build_image_to_3d_workflow(image_name, ckpt_name='hunyuan3d-dit-v2-mini.safetensors', *, resolution, steps, cfg, seed, octree_resolution, num_chunks, threshold, ..., watertight=False) -> dict` | Builder del workflow imagen→3D de 9 nodos (Hunyuan3D-2 nativo) en API format. El SaveGLB produce un `.glb`. `watertight=True` usa `VoxelToMesh` (`algorithm='surface net'`) en vez de `VoxelToMeshBasic` → malla estanca de raíz (default conserva el comportamiento histórico). **Pura**. |
|
||||
| [comfyui_generate_views_from_image_py_ml](../../python/functions/ml/comfyui_generate_views_from_image.md) | `generate_views_from_image(image_name, *, method='auto', server, azimuths=(90,180,270), elevation, dest_dir, validate_only=False, ...) -> dict` | Sintetiza vistas novel-view (back/left/right) desde 1 imagen con StableZero123/SV3D nativos, para alimentar el 3D multi-vista. **Honesta**: si el nodo+checkpoint no están, devuelve `ok=False` con la acción y NO encola. `validate_only=True` valida sin tocar GPU. Impura. |
|
||||
| [comfyui_build_view_3d_workflow_py_ml](../../python/functions/ml/comfyui_build_view_3d_workflow.md) | `build_view_3d_workflow(model_file, *, animation=False, width, height) -> dict` | Monta el visor 3D nativo `Load3D` (o `Load3DAdvanced` con `animation=True`) para VER un GLB/OBJ existente, orbitando con el ratón, sin ejecutar el grafo. `model_file` relativo a `input/3d/`. Cárgalo con `load_workflow_ui`. **Pura**. |
|
||||
| [comfyui_fetch_output_mesh_py_ml](../../python/functions/ml/comfyui_fetch_output_mesh.md) | `fetch_output_mesh(prompt_id, *, server, dest=None, timeout) -> dict` | Localiza la malla en `/history/{prompt_id}` (el SaveGLB la expone bajo la clave `"3d"`, no `"images"`) y la baja via GET `/view` a disco. Hermana de `fetch_output_image`. Impura. |
|
||||
| [comfyui_install_3d_model_py_ml](../../python/functions/ml/comfyui_install_3d_model.md) | `install_3d_model(variant='mini', *, hf_token=None, comfyui_dir) -> dict` | Instala el checkpoint Hunyuan3D-2 (mini/standard/mv) en `checkpoints/`. Cascada: ya-instalado → cache de HF → descarga. Resuelve la ruta real via `extra_model_paths.yaml`. Impura. |
|
||||
| [comfyui_image_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_image_to_3d_oneshot.md) | `image_to_3d_oneshot(image_path, *, server, variant='mini', dest=None, wait_timeout, **gen) -> dict` | **Pipeline** imagen en disco → malla GLB en una llamada: upload + build + submit + wait + fetch. Promoción de la secuencia (issue 0087). Impuro. |
|
||||
| [comfyui_text_to_3d_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_text_to_3d_oneshot.md) | `text_to_3d_oneshot(prompt, *, server, ckpt_name='v1-5-pruned-emaonly.safetensors', negative='', textured=False, variant='mini', dest=None, ...) -> dict` | **Pipeline** prompt de texto → malla 3D GLB en una llamada: txt2img (SD) + fetch + upload + build 3D (nativo o `textured=True` multi-vista PBR) + submit + wait + fetch_mesh. Promoción de la secuencia texto→imagen→3D (issue 0087). Impuro. |
|
||||
| [comfyui_build_textured_3d_multiview_workflow_py_ml](../../python/functions/ml/comfyui_build_textured_3d_multiview_workflow.md) | `build_textured_3d_multiview_workflow(image_name, *, ckpt='hunyuan3d-dit-v2-mv.safetensors', views=6, octree=384, max_faces=50000, upscale_model='4x_foolhardy_Remacri.pth') -> dict` | Builder imagen→malla 3D **con textura PBR** vía el wrapper Hunyuan3DWrapper (kijai): 4/6 vistas + delight + sample multi-vista + upscale Remacri + bake sobre UV (19 nodos). Cobertura de atlas 32.93% (report 0082). **Pura**. En 8 GB ejecutar en 2 fases (shape→`/free`→paint). |
|
||||
| [comfyui_simplify_mesh_py_ml](../../python/functions/ml/comfyui_simplify_mesh.md) | `simplify_mesh(in_path, *, target_faces=80000, weld=True, out_path=None) -> dict` | **Post-proceso**: decima un GLB/OBJ/PLY denso (suelda cube-soup + quadric edge collapse de pymeshlab), conservando vertex colors o textura+UV. 964k→80k caras, 34.7→1.43 MB medido (report 0090). `weld=True` es clave: sin él la cube-soup de `VoxelToMeshBasic` no decima. Impura (trimesh+pymeshlab+scipy). |
|
||||
| [comfyui_make_watertight_py_ml](../../python/functions/ml/comfyui_make_watertight.md) | `make_watertight(in_path, *, method='voxel', pitch=None, out_path=None) -> dict` | **Post-proceso**: hace estanca una malla. `method='voxel'` (voxeliza+fill+marching cubes) garantiza `is_watertight=True` a costa de más caras y de descartar la apariencia; `method='repair'` (fill_holes+fix_normals) conserva detalle pero no garantiza estanqueidad. La vía de raíz es `VoxelToMesh surface net` (report 0088). Impura. |
|
||||
| [comfyui_mesh_cleanup_oneshot_py_pipelines](../../python/functions/pipelines/comfyui_mesh_cleanup_oneshot.md) | `mesh_cleanup_oneshot(in_path, *, target_faces=80000, watertight=True, method='repair', out_path=None) -> dict` | **Pipeline** de limpieza en una llamada: `simplify_mesh` → (si `watertight`) `make_watertight`. Capitaliza el "80k caras + estanco" del report 0088. `method='voxel'` garantiza estanqueidad; `method='repair'` conserva caras. Reporta `{in_faces, simplified_faces, final_faces, is_watertight}`. Impuro. |
|
||||
|
||||
### Por la UI web (CDP) — dominio `browser`
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [comfyui_load_workflow_ui_py_browser](../../python/functions/browser/comfyui_load_workflow_ui.md) | `load_workflow_ui(workflow, *, port=9222, server_url_substr='8188', filename, timeout_s) -> dict` | Carga un workflow API format en el grafo visual (`app.loadApiJson`). Impura (CDP + muta UI). |
|
||||
| [comfyui_set_node_widget_ui_py_browser](../../python/functions/browser/comfyui_set_node_widget_ui.md) | `set_node_widget_ui(node, widget_name, value, *, match='type', port, server_url_substr, timeout_s) -> dict` | Edita en vivo un widget de un nodo (texto del CLIPTextEncode, steps/seed/cfg del KSampler). Localiza por type/id/title. Impura. |
|
||||
| [comfyui_queue_prompt_ui_py_browser](../../python/functions/browser/comfyui_queue_prompt_ui.md) | `queue_prompt_ui(*, port, server_url_substr, timeout_s) -> dict` | Encola el grafo actual (`app.queuePrompt(0)`), = botón "Queue Prompt". Impura (dispara GPU). |
|
||||
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
|
||||
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
|
||||
|
||||
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
||||
|
||||
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
||||
prompt y los pasos en vivo, encolas y esperas el PNG. Requiere el server en `127.0.0.1:8188`
|
||||
y la pestaña de ComfyUI abierta en un Chrome con `--remote-debugging-port=9222`.
|
||||
|
||||
```python
|
||||
import sys, os, time, glob
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
|
||||
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
|
||||
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
|
||||
|
||||
# 1. Construir (API format, función pura) con un prefijo de salida localizable.
|
||||
prefix = f"demo_{int(time.time())}"
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="placeholder",
|
||||
steps=8, seed=111, filename_prefix=prefix,
|
||||
)
|
||||
|
||||
# 2. Cargar el grafo en la UI del navegador del usuario.
|
||||
comfyui_load_workflow_ui(wf) # {'ok': True, 'loaded': True}
|
||||
|
||||
# 3. Tuning en vivo: prompt (widget de texto) + pasos (widget numérico).
|
||||
comfyui_set_node_widget_ui("CLIPTextEncode", "text",
|
||||
"a green glass bottle on a marble shelf", match="type")
|
||||
comfyui_set_node_widget_ui("KSampler", "steps", 12, match="type")
|
||||
|
||||
# 4. Encolar (= pulsar "Queue Prompt") y localizar el PNG nuevo en output/.
|
||||
comfyui_queue_prompt_ui() # {'ok': True, 'queued': True}
|
||||
before = set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png")))
|
||||
while True:
|
||||
new = [p for p in set(glob.glob(os.path.expanduser("~/ComfyUI/output/*.png"))) - before
|
||||
if prefix in os.path.basename(p)]
|
||||
if new:
|
||||
print("PNG generado:", new[0]); break
|
||||
time.sleep(1.5)
|
||||
```
|
||||
|
||||
Variante 100% headless (sin navegador): cambia los pasos 2-4 por
|
||||
`comfyui_submit_workflow(wf)` → `comfyui_wait_result(prompt_id)`. Misma capacidad, sin UI.
|
||||
|
||||
## Ejemplo canónico imagen → 3D (Hunyuan3D-2 nativo)
|
||||
|
||||
Una imagen de un objeto → su malla GLB, en una sola llamada. Requiere el server en
|
||||
`127.0.0.1:8188` y el checkpoint mini instalado (lo hace `install_3d_model` la primera vez,
|
||||
reutilizando la cache de HF; ~60 s de GPU por reconstrucción en una RTX 3070).
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_install_3d_model import comfyui_install_3d_model
|
||||
from pipelines.comfyui_image_to_3d_oneshot import comfyui_image_to_3d_oneshot
|
||||
|
||||
# 1. Asegurar el checkpoint (instantáneo si ya está; reused_cache=True).
|
||||
comfyui_install_3d_model("mini")
|
||||
|
||||
# 2. Imagen en disco -> malla GLB en /tmp/meshes.
|
||||
res = comfyui_image_to_3d_oneshot(
|
||||
os.path.expanduser("~/ComfyUI/input/3d_src_robot_00001_.png"),
|
||||
dest="/tmp/meshes", variant="mini", seed=42,
|
||||
)
|
||||
print(res["mesh_path"], res["faces"]) # /tmp/meshes/3d_mesh_00001_.glb 1668040
|
||||
```
|
||||
|
||||
Para tunear nodo a nodo en vez del oneshot: `build_image_to_3d_workflow(image_name)` →
|
||||
`submit_workflow` → `wait_result` → `fetch_output_mesh(prompt_id, dest=...)`.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No es un grupo de generación genérica de imágenes**: cubre ComfyUI concretamente (su API
|
||||
y su frontend litegraph). Para otros backends (Automatic1111, diffusers) harían falta otras
|
||||
funciones.
|
||||
- **Los builders cubren txt2img, img2img, upscale (ESRGAN y hires-fix con re-difusión), LoRA
|
||||
stacks, inpaint, ControlNet, SDXL refiner, FaceDetailer, vídeo (LTX/Wan) y 3D texturizado
|
||||
multi-vista** (`build_txt2img_workflow`, `build_img2img_workflow`, `build_upscale_workflow`,
|
||||
`build_hires_fix_workflow`, `inject_lora`, `build_inpaint_workflow`, `build_controlnet_workflow`,
|
||||
`build_sdxl_refiner_workflow`, `build_facedetailer_workflow`, `build_video_workflow`,
|
||||
`build_textured_3d_multiview_workflow`). Lo que aún NO tiene builder propio (IPAdapter,
|
||||
multi-ControlNet avanzado) se monta en la UI a mano y se captura con `export_workflow_ui`, o se
|
||||
importa de internet con `import_workflow_json`/`import_workflow_png`, se resuelven sus dependencias
|
||||
con `resolve_workflow_deps` (instala nodos con `install_custom_node`, descubre modelos con
|
||||
`search_civitai_models`) y se valida con `validate_workflow` antes de encolar.
|
||||
- **Los 13 builders puros tienen tests de estructura** (`python/functions/ml/tests/test_comfyui_build_*.py`
|
||||
+ `test_comfyui_inject_lora.py`): verifican los `class_type` esperados, que los parámetros se reflejan
|
||||
en los nodos, la validez de las conexiones `[node_id, output_index]` y la pureza de `inject_lora`. Son
|
||||
tests offline (no tocan GPU ni server); las funciones impuras del grupo (todo lo que habla con el server,
|
||||
el navegador o Civitai/HuggingFace) no se cubren con unit tests por diseño — se validan con el server vivo.
|
||||
- **Control de cola**: `interrupt_queue` corta la generación en curso + lee `/queue`; `batch_generate`
|
||||
encola N variantes por seed (re-roll). No vacían la cola entera (eso es `POST /queue {"clear": true}`).
|
||||
- **Las funciones `*_ui` requieren la pestaña abierta y el navegador con CDP** (puerto 9222 por
|
||||
defecto). Sin target que matchee `server_url_substr`, devuelven `ok=False`. Para automatización
|
||||
desatendida sin navegador, usa el camino API (`submit_workflow` + `wait_result`).
|
||||
- **`download_model` no gestiona el catálogo del server**: tras bajar un modelo, llama
|
||||
`refresh_nodes_ui` (o recarga la página) para que ComfyUI lo vea en los combos.
|
||||
- **El camino imagen→3D nativo es shape-only**: los nodos nativos de Hunyuan3D-2
|
||||
(`build_image_to_3d_workflow`, `fetch_output_mesh`, `install_3d_model`, `image_to_3d_oneshot`)
|
||||
reconstruyen la FORMA, sin color ni textura horneada. Para **textura PBR** está
|
||||
`build_textured_3d_multiview_workflow`, que usa el wrapper de kijai (requiere `custom_rasterizer`
|
||||
CUDA + `ComfyUI_essentials` + el upscaler Remacri) y debe ejecutarse en 2 fases en 8 GB
|
||||
(shape→`/free`→paint). Detalle y cobertura medida en `reports/0082`; shape-only y comparación vs la
|
||||
app local en `reports/0069-2026-06-23-comfyui-img-to-3d.md`.
|
||||
- **Estanqueidad de la malla**: el default de `build_image_to_3d_workflow` (`VoxelToMeshBasic`) da
|
||||
malla NO estanca; con `watertight=True` (`VoxelToMesh surface-net`) sale estanca de raíz. Si ya
|
||||
tienes el GLB en disco, `mesh_cleanup_oneshot` decima + cierra en una llamada (`method='voxel'`
|
||||
garantiza `is_watertight=True`; `method='repair'` conserva caras sin garantía). Ver `reports/0088`.
|
||||
- La primitiva de transport CDP es [`cdp_eval`](../../python/functions/browser/cdp_eval.md) (grupo
|
||||
navegador): si necesitas leer/escribir algo del grafo que estas funciones no cubren, compón
|
||||
`cdp_eval` directamente antes de inventar nada.
|
||||
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
|
||||
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
||||
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
||||
|
||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
|
||||
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
|
||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
|
||||
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
|
||||
de fondo con los dos pasos de reconstruccion:
|
||||
|
||||
```
|
||||
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
|
||||
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
|
||||
```
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace |
|
||||
|---|---|---|
|
||||
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
|
||||
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
|
||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
|
||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||
|
||||
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
|
||||
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||
|
||||
## Ejemplo canonico (end-to-end imagen → glb)
|
||||
|
||||
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
||||
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from remove_background import remove_background
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
from depth_to_relief_glb import depth_to_relief_glb
|
||||
|
||||
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
||||
OUT = "/tmp/cats_relief.glb"
|
||||
|
||||
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
|
||||
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
|
||||
assert cut["status"] == "ok"
|
||||
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
|
||||
|
||||
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
||||
assert est["status"] == "ok"
|
||||
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
|
||||
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
|
||||
assert res["status"] == "ok"
|
||||
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
||||
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
||||
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
||||
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
||||
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
||||
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
|
||||
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
|
||||
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
|
||||
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
|
||||
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
|
||||
datascience). Ver gotchas en cada `.md`.
|
||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
|
||||
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
|
||||
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
|
||||
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
|
||||
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
|
||||
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
|
||||
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
|
||||
en cada `.md`.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
||||
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
|
||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
|
||||
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
|
||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
|
||||
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
|
||||
`remove_background` cae al umbral NumPy).
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Capability: sql-connect
|
||||
|
||||
Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager).
|
||||
|
||||
Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. |
|
||||
| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. |
|
||||
| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. |
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos):
|
||||
|
||||
```bash
|
||||
cd /home/egutierrez/fn_registry
|
||||
MSSQL_PASSWORD=$(pass navision/password) \
|
||||
./fn run run_mssql_query \
|
||||
--host 10.0.0.5 --database navdb --user sa \
|
||||
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
|
||||
--param CLI-0001 \
|
||||
--format csv
|
||||
```
|
||||
|
||||
Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces):
|
||||
|
||||
```python
|
||||
import os, sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.mssql_connect import mssql_connect
|
||||
from infra.mssql_query import mssql_query
|
||||
|
||||
conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"])
|
||||
try:
|
||||
abiertos = mssql_query(
|
||||
conn,
|
||||
"SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s",
|
||||
params=("CLI-0001",),
|
||||
)
|
||||
print(abiertos["row_count"], abiertos["columns"])
|
||||
posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]")
|
||||
print(posted["rows"])
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales.
|
||||
- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`.
|
||||
- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección).
|
||||
- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga).
|
||||
- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit.
|
||||
- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta.
|
||||
- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real.
|
||||
- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook.
|
||||
- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura.
|
||||
|
||||
## Relación con otros grupos
|
||||
|
||||
- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI.
|
||||
- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: comfyui_export_workflow_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_export_workflow_ui(*, port: int = 9222, server_url_substr: str = '8188', api_format: bool = True, save_path: str | None = None, timeout_s: float = 15.0) -> dict"
|
||||
description: "Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP. Con api_format=True devuelve el API format ((await app.graphToPrompt()).output, listo para POST /prompt); con False el UI graph serializado (app.graph.serialize(), recargable en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval. Impura: red (CDP) + escritura opcional."
|
||||
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "os"]
|
||||
params:
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||
- name: server_url_substr
|
||||
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||
- name: api_format
|
||||
desc: "True devuelve el API format (POST /prompt); False el UI graph serializado (recargable con la UI). Default True."
|
||||
- name: save_path
|
||||
desc: "Si se pasa, ruta donde escribir el JSON (se expande ~ y se crean los padres). None no escribe a disco."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||
output: "dict {ok: bool, workflow: dict, saved_to: str|None, error: str}. workflow es el API format o el UI graph segun api_format; saved_to es la ruta escrita o None."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_export_workflow_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.comfyui_export_workflow_ui import comfyui_export_workflow_ui
|
||||
|
||||
# Captura el API format del grafo actual y guardalo a disco.
|
||||
out = comfyui_export_workflow_ui(api_format=True, save_path="/tmp/wf_actual.json")
|
||||
print(out["ok"], len(out["workflow"]), "nodos ->", out["saved_to"])
|
||||
|
||||
# El API format devuelto es re-enviable por API:
|
||||
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||
resp = comfyui_submit_workflow(out["workflow"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para capturar lo que el usuario tiene montado en la UI y (a) re-enviarlo por API
|
||||
con `comfyui_submit_workflow`, (b) persistirlo como plantilla, o (c) verificar
|
||||
que un cambio hecho con `comfyui_set_node_widget_ui` quedo reflejado en el grafo.
|
||||
Es el reverso de `comfyui_load_workflow_ui`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `api_format=True` da el formato de POST /prompt (sin links visuales ni
|
||||
posiciones); `api_format=False` da el grafo de UI (con todo lo necesario para
|
||||
`app.loadGraphData`). Elige segun si vas a re-enviar por API o a recargar en UI.
|
||||
- `graphToPrompt()` es asincrono: se espera la Promise (`await_promise=True`). Si
|
||||
la pestana no tiene `window.app`, devuelve `ok=False` con error claro.
|
||||
- El export refleja el estado EN VIVO del grafo, incluidos los cambios de
|
||||
`comfyui_set_node_widget_ui` aplicados antes.
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Exporta el workflow actual del grafo de ComfyUI desde la UI via CDP.
|
||||
|
||||
Con api_format=True devuelve el API format (el dict que acepta POST /prompt,
|
||||
extraido de `(await app.graphToPrompt()).output`); con False devuelve el UI graph
|
||||
serializado (`app.graph.serialize()`, con links y posiciones para volver a
|
||||
cargar en la UI). Opcionalmente escribe el JSON a disco. Compone cdp_eval.
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y, si save_path, escribe en disco.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||
from cdp_eval import cdp_eval
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
|
||||
|
||||
def comfyui_export_workflow_ui(
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
api_format: bool = True,
|
||||
save_path: str | None = None,
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Exporta el workflow actual del grafo de la UI de ComfyUI.
|
||||
|
||||
Args:
|
||||
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||
api_format: True devuelve el API format (POST /prompt); False devuelve el
|
||||
UI graph serializado (recargable con la UI). Default True.
|
||||
save_path: si se pasa, ruta donde escribir el JSON exportado. Se expande
|
||||
~ y se crean los directorios padre. None no escribe a disco.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, workflow: dict, saved_to: str|None, error: str}.
|
||||
"""
|
||||
if api_format:
|
||||
expr = (
|
||||
"(async function(){"
|
||||
" if(!window.app || typeof app.graphToPrompt!=='function'){"
|
||||
" return {error:'window.app.graphToPrompt no disponible en la pestana'};"
|
||||
" }"
|
||||
" try{ var p = await app.graphToPrompt(); return {workflow: p.output, error:''}; }"
|
||||
" catch(e){ return {error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
await_p = True
|
||||
else:
|
||||
expr = (
|
||||
"(function(){"
|
||||
" if(!window.app || !app.graph || typeof app.graph.serialize!=='function'){"
|
||||
" return {error:'window.app.graph.serialize no disponible en la pestana'};"
|
||||
" }"
|
||||
" try{ return {workflow: app.graph.serialize(), error:''}; }"
|
||||
" catch(e){ return {error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
await_p = False
|
||||
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=server_url_substr,
|
||||
await_promise=await_p,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not r["ok"]:
|
||||
return {"ok": False, "workflow": {}, "saved_to": None, "error": r["error"]}
|
||||
val = r["value"] or {}
|
||||
if val.get("error"):
|
||||
return {"ok": False, "workflow": {}, "saved_to": None, "error": val["error"]}
|
||||
|
||||
workflow = val.get("workflow") or {}
|
||||
saved_to = None
|
||||
if save_path:
|
||||
path = os.path.expanduser(save_path)
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(workflow, fh, ensure_ascii=False, indent=2)
|
||||
saved_to = path
|
||||
|
||||
return {"ok": True, "workflow": workflow, "saved_to": saved_to, "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = comfyui_export_workflow_ui(api_format=True)
|
||||
print(json.dumps(
|
||||
{"ok": out["ok"], "nodes": len(out["workflow"]), "error": out["error"]},
|
||||
ensure_ascii=False, indent=2,
|
||||
))
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: comfyui_load_workflow_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_load_workflow_ui(workflow: dict, *, port: int = 9222, server_url_substr: str = '8188', filename: str = 'workflow.json', timeout_s: float = 20.0) -> dict"
|
||||
description: "Carga un workflow ComfyUI (API format) en la UI del navegador via CDP: inyecta app.loadApiJson(<workflow>, filename) en la pestana de ComfyUI abierta y reconstruye el grafo visual. Compone cdp_eval (transport CDP). Impura: red (CDP WebSocket) + muta el grafo de la UI."
|
||||
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json"]
|
||||
params:
|
||||
- name: workflow
|
||||
desc: "dict en API format (claves = node_ids, valores con class_type + inputs); tipicamente el resultado de comfyui_build_txt2img_workflow."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||
- name: server_url_substr
|
||||
desc: "Substring de la URL de la pestana de ComfyUI (default '8188', el puerto del server). Identifica la pestana entre las abiertas."
|
||||
- name: filename
|
||||
desc: "Nombre que ComfyUI asocia al workflow cargado. Default 'workflow.json'."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
|
||||
output: "dict {ok: bool, loaded: bool, error: str}. ok/loaded True si app.loadApiJson termino sin excepcion en la pagina."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_load_workflow_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
)
|
||||
# Requiere la UI de ComfyUI abierta en el Chrome con CDP en el puerto 9222.
|
||||
print(comfyui_load_workflow_ui(wf)) # -> {'ok': True, 'loaded': True, 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tienes un workflow en API format (lo construyo con
|
||||
`comfyui_build_txt2img_workflow` o lo exporto de otro lado) y quieres verlo y
|
||||
editarlo en la UI del navegador del usuario antes de encolarlo. Es el puente
|
||||
"API format -> grafo visual": cargas, luego ajustas widgets con
|
||||
`comfyui_set_node_widget_ui` y encolas con `comfyui_queue_prompt_ui`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere que la pestana de ComfyUI ya este abierta en un Chrome con
|
||||
`--remote-debugging-port=9222`. Si no hay target que matchee `server_url_substr`,
|
||||
`cdp_eval` devuelve error y aqui `ok=False`.
|
||||
- `app.loadApiJson` REEMPLAZA el grafo actual de la UI por el del workflow; pierde
|
||||
los cambios no exportados. Exporta antes con `comfyui_export_workflow_ui` si los
|
||||
necesitas.
|
||||
- Espera la Promise de carga (`await_promise=True`). El conteo de nodos cargados
|
||||
se puede verificar con `cdp_eval("app.graph._nodes.length", target_url_substr="8188")`.
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Carga un workflow ComfyUI (API format) en la UI del navegador via CDP.
|
||||
|
||||
Inyecta `app.loadApiJson(<workflow>, filename)` en la pestana de ComfyUI ya
|
||||
abierta en el navegador diario, reconstruyendo el grafo visual a partir del API
|
||||
format (el mismo dict que produce comfyui_build_txt2img_workflow). Compone la
|
||||
primitiva de transport cdp_eval; no abre ventana nueva ni reinventa CDP.
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
|
||||
"""
|
||||
import json
|
||||
|
||||
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||
from cdp_eval import cdp_eval
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
|
||||
|
||||
def comfyui_load_workflow_ui(
|
||||
workflow: dict,
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
filename: str = "workflow.json",
|
||||
timeout_s: float = 20.0,
|
||||
) -> dict:
|
||||
"""Carga un workflow API format en el grafo de la UI de ComfyUI.
|
||||
|
||||
Args:
|
||||
workflow: dict en API format (claves = node_ids, valores con class_type +
|
||||
inputs). Tipicamente el resultado de comfyui_build_txt2img_workflow.
|
||||
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||
server_url_substr: substring de la URL de la pestana de ComfyUI (default
|
||||
"8188", el puerto del server). Identifica la pestana entre todas las
|
||||
abiertas.
|
||||
filename: nombre que ComfyUI asocia al workflow cargado.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, loaded: bool, error: str}. ok/loaded True si
|
||||
app.loadApiJson termino sin excepcion en la pagina.
|
||||
"""
|
||||
expr = (
|
||||
"(async function(){"
|
||||
" if(!window.app || typeof app.loadApiJson!=='function'){"
|
||||
" return {loaded:false, error:'window.app.loadApiJson no disponible en la pestana'};"
|
||||
" }"
|
||||
" try{"
|
||||
f" await app.loadApiJson({json.dumps(workflow)}, {json.dumps(filename)});"
|
||||
" return {loaded:true, error:'', nodes: app.graph? app.graph._nodes.length : -1};"
|
||||
" }catch(e){ return {loaded:false, error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=server_url_substr,
|
||||
await_promise=True,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not r["ok"]:
|
||||
return {"ok": False, "loaded": False, "error": r["error"]}
|
||||
val = r["value"] or {}
|
||||
return {
|
||||
"ok": bool(val.get("loaded")),
|
||||
"loaded": bool(val.get("loaded")),
|
||||
"error": val.get("error", ""),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
)
|
||||
print(json.dumps(comfyui_load_workflow_ui(wf), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: comfyui_queue_prompt_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_queue_prompt_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 20.0) -> dict"
|
||||
description: "Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar 'Queue Prompt'): llama app.queuePrompt(0) en la pestana, que serializa el grafo al API format y hace POST /prompt al server. Compone cdp_eval. Impura: red (CDP) + dispara trabajo de GPU."
|
||||
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json"]
|
||||
params:
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||
- name: server_url_substr
|
||||
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 20.0."
|
||||
output: "dict {ok: bool, queued: bool, error: str}. queued True si app.queuePrompt resolvio sin excepcion."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_queue_prompt_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.comfyui_queue_prompt_ui import comfyui_queue_prompt_ui
|
||||
from ml.comfyui_wait_result import comfyui_wait_result
|
||||
|
||||
print(comfyui_queue_prompt_ui()) # -> {'ok': True, 'queued': True, 'error': ''}
|
||||
# El PNG aparece en ~/ComfyUI/output/. Para esperar el resultado por API se usa
|
||||
# el prompt_id; si solo encolas desde la UI, sondea la carpeta output/ o usa el
|
||||
# historial (GET /history) para localizar el archivo nuevo.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Como ultimo paso del flujo por UI: tras cargar (`comfyui_load_workflow_ui`) y
|
||||
ajustar widgets (`comfyui_set_node_widget_ui`), dispara la generacion sin que el
|
||||
usuario pulse el boton. Reproduce exactamente "Queue Prompt" del frontend.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Tiene efecto secundario real: arranca trabajo de GPU en el server. No es
|
||||
idempotente — cada llamada encola un prompt nuevo.
|
||||
- `app.queuePrompt(0)` encola el grafo TAL CUAL esta en la UI en ese momento, no
|
||||
un workflow que le pases. Para encolar uno concreto, cargalo antes con
|
||||
`comfyui_load_workflow_ui`.
|
||||
- No devuelve el `prompt_id` (la UI lo gestiona internamente). Para correlar el
|
||||
resultado por API mejor usa `comfyui_submit_workflow` (devuelve prompt_id) +
|
||||
`comfyui_wait_result`; esta funcion es para el caso "como si pulsara el boton".
|
||||
- Si el grafo tiene errores de validacion, ComfyUI los muestra en la UI y la
|
||||
Promise puede rechazar: aqui se refleja como `ok=False` con el error.
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Encola el grafo actual de ComfyUI desde la UI (equivale a pulsar "Queue Prompt").
|
||||
|
||||
Llama `app.queuePrompt(0)` en la pestana de ComfyUI abierta en el navegador, que
|
||||
serializa el grafo visual al API format y hace POST /prompt al server. Compone
|
||||
cdp_eval.
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y dispara trabajo de GPU en el server.
|
||||
"""
|
||||
import json
|
||||
|
||||
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||
from cdp_eval import cdp_eval
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
|
||||
|
||||
def comfyui_queue_prompt_ui(
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
timeout_s: float = 20.0,
|
||||
) -> dict:
|
||||
"""Encola el grafo actual de la UI de ComfyUI.
|
||||
|
||||
Args:
|
||||
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, queued: bool, error: str}. queued True si
|
||||
app.queuePrompt resolvio sin excepcion.
|
||||
"""
|
||||
expr = (
|
||||
"(async function(){"
|
||||
" if(!window.app || typeof app.queuePrompt!=='function'){"
|
||||
" return {queued:false, error:'window.app.queuePrompt no disponible en la pestana'};"
|
||||
" }"
|
||||
" try{ await app.queuePrompt(0); return {queued:true, error:''}; }"
|
||||
" catch(e){ return {queued:false, error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=server_url_substr,
|
||||
await_promise=True,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not r["ok"]:
|
||||
return {"ok": False, "queued": False, "error": r["error"]}
|
||||
val = r["value"] or {}
|
||||
return {
|
||||
"ok": bool(val.get("queued")),
|
||||
"queued": bool(val.get("queued")),
|
||||
"error": val.get("error", ""),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(comfyui_queue_prompt_ui(), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: comfyui_refresh_nodes_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_refresh_nodes_ui(*, port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||
description: "Refresca los combos del grafo de ComfyUI desde la UI via CDP: llama app.refreshComboInNodes(), que vuelve a pedir GET /object_info y actualiza los combos de todos los nodos (checkpoints, loras, vae, samplers) sin recargar la pagina. Util tras descargar modelos nuevos. Compone cdp_eval. Impura: red (CDP) + refresca estado de la UI."
|
||||
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json"]
|
||||
params:
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||
- name: server_url_substr
|
||||
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||
output: "dict {ok: bool, refreshed: bool, error: str}. refreshed True si app.refreshComboInNodes resolvio sin excepcion."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_refresh_nodes_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_download_model import comfyui_download_model
|
||||
from browser.comfyui_refresh_nodes_ui import comfyui_refresh_nodes_ui
|
||||
|
||||
# Tras bajar un checkpoint nuevo, refresca los combos para que aparezca en los
|
||||
# CheckpointLoaderSimple sin recargar la pagina.
|
||||
comfyui_download_model("https://.../nuevo.safetensors", "checkpoints")
|
||||
print(comfyui_refresh_nodes_ui()) # -> {'ok': True, 'refreshed': True, 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Justo despues de añadir modelos a `~/ComfyUI/models/` (con
|
||||
`comfyui_download_model` o a mano) para que los nodos de la UI vean los archivos
|
||||
nuevos en sus combos sin un F5 que perderia el grafo no guardado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Solo refresca combos (listas que vienen de /object_info): checkpoints, loras,
|
||||
vae, samplers, schedulers. NO recarga el grafo ni cambia los valores ya
|
||||
seleccionados.
|
||||
- Si el server no ve aun el archivo nuevo (lo copiaste a la carpeta equivocada o
|
||||
ComfyUI no reescanea), el combo seguira sin mostrarlo aunque `refreshed=True`:
|
||||
el refresh fue exitoso pero el catalogo del server no lo incluye.
|
||||
- Requiere la pestana de ComfyUI abierta en el Chrome con CDP; sin target,
|
||||
`ok=False`.
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Refresca los combos del grafo de ComfyUI desde la UI via CDP.
|
||||
|
||||
Llama `app.refreshComboInNodes()`, que vuelve a pedir GET /object_info al server
|
||||
y actualiza los combos de todos los nodos (lista de checkpoints, loras, vaes,
|
||||
samplers) sin recargar la pagina. Util tras descargar modelos nuevos con
|
||||
comfyui_download_model para que aparezcan en los CheckpointLoaderSimple sin un
|
||||
F5. Compone cdp_eval.
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y refresca estado de la UI.
|
||||
"""
|
||||
import json
|
||||
|
||||
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||
from cdp_eval import cdp_eval
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
|
||||
|
||||
def comfyui_refresh_nodes_ui(
|
||||
*,
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Refresca los combos (checkpoints/loras/vae) de los nodos del grafo.
|
||||
|
||||
Args:
|
||||
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, refreshed: bool, error: str}. refreshed True si
|
||||
app.refreshComboInNodes resolvio sin excepcion.
|
||||
"""
|
||||
expr = (
|
||||
"(async function(){"
|
||||
" if(!window.app || typeof app.refreshComboInNodes!=='function'){"
|
||||
" return {refreshed:false, error:'window.app.refreshComboInNodes no disponible en la pestana'};"
|
||||
" }"
|
||||
" try{ await app.refreshComboInNodes(); return {refreshed:true, error:''}; }"
|
||||
" catch(e){ return {refreshed:false, error:String(e)}; }"
|
||||
"})()"
|
||||
)
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=server_url_substr,
|
||||
await_promise=True,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not r["ok"]:
|
||||
return {"ok": False, "refreshed": False, "error": r["error"]}
|
||||
val = r["value"] or {}
|
||||
return {
|
||||
"ok": bool(val.get("refreshed")),
|
||||
"refreshed": bool(val.get("refreshed")),
|
||||
"error": val.get("error", ""),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(comfyui_refresh_nodes_ui(), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: comfyui_set_node_widget_ui
|
||||
kind: function
|
||||
lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_set_node_widget_ui(node: str, widget_name: str, value, *, match: str = 'type', port: int = 9222, server_url_substr: str = '8188', timeout_s: float = 15.0) -> dict"
|
||||
description: "Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP. Localiza el nodo en app.graph._nodes por type (comfyClass), id o title; asigna widget.value, invoca widget.callback si existe y marca el canvas dirty. Cubre widgets numericos (steps/cfg/seed) y de texto (CLIPTextEncode.text). Compone cdp_eval. Impura: red (CDP) + muta el grafo."
|
||||
tags: [comfyui, browser, cdp, ml, image-generation, stable-diffusion, ui-automation]
|
||||
uses_functions: ["cdp_eval_py_browser"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json"]
|
||||
params:
|
||||
- name: node
|
||||
desc: "Identificador del nodo a localizar, interpretado segun `match`."
|
||||
- name: widget_name
|
||||
desc: "Nombre del widget a editar (ej. 'text', 'steps', 'seed', 'cfg', 'sampler_name')."
|
||||
- name: value
|
||||
desc: "Nuevo valor (str, int, float o bool). Se serializa a JSON para inyectarlo."
|
||||
- name: match
|
||||
desc: "Criterio de busqueda: 'type' (por comfyClass/type, ej. 'CLIPTextEncode'/'KSampler'), 'id' (por n.id) o 'title' (por titulo visible). Default 'type'."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome diario. Default 9222."
|
||||
- name: server_url_substr
|
||||
desc: "Substring de la URL de la pestana de ComfyUI. Default '8188'."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la conexion CDP en segundos. Default 15.0."
|
||||
output: "dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}. Con match='type' y varios matches, actua sobre el primero y reporta cuantos coincidieron."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/browser/comfyui_set_node_widget_ui.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.comfyui_set_node_widget_ui import comfyui_set_node_widget_ui
|
||||
|
||||
# Cambiar el prompt positivo (widget de texto del CLIPTextEncode) ...
|
||||
print(comfyui_set_node_widget_ui(
|
||||
"CLIPTextEncode", "text", "a blue ceramic mug, studio light", match="type"))
|
||||
# ... y los pasos del sampler (widget numerico).
|
||||
print(comfyui_set_node_widget_ui("KSampler", "steps", 25, match="type"))
|
||||
# -> {'ok': True, 'matched_nodes': 2, 'set': True, 'old_value': 20, 'new_value': 25, 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para ajustar parametros de un workflow ya cargado en la UI sin reconstruirlo:
|
||||
cambiar el prompt, los steps, la seed, el cfg o el sampler en vivo antes de
|
||||
encolar con `comfyui_queue_prompt_ui`. Es el paso de "tuning" entre
|
||||
`comfyui_load_workflow_ui` y la cola.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Con `match="type"` y un workflow txt2img hay DOS `CLIPTextEncode` (positivo y
|
||||
negativo): `matched_nodes=2` y solo se edita el primero (el positivo en el grafo
|
||||
por defecto). Para apuntar al negativo usa `match="id"` o `match="title"`.
|
||||
- Nodo o widget inexistente NO lanza: devuelve `ok=False`, `set=False` y un
|
||||
`error` claro ("sin nodo que matchee ..." / "el nodo no tiene widget ...").
|
||||
- `widget.callback` se invoca con el nuevo valor para propagar el cambio (combos,
|
||||
derivados); si el callback de un widget concreto espera mas argumentos, el fallo
|
||||
se traga (try/catch) y el `value` ya queda asignado igualmente.
|
||||
- El cambio vive en el grafo de la UI; para persistirlo a un archivo exportalo con
|
||||
`comfyui_export_workflow_ui` o encolalo.
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Edita en vivo el valor de un widget de un nodo del grafo de ComfyUI via CDP.
|
||||
|
||||
Localiza un nodo en `app.graph._nodes` por su tipo (comfyClass), su id o su
|
||||
titulo, y asigna el valor del widget cuyo `name` coincide. Cubre tanto widgets
|
||||
numericos (steps, cfg, seed del KSampler) como de texto (el `text` de un
|
||||
CLIPTextEncode). Tras asignar `widget.value` invoca `widget.callback` si existe
|
||||
para propagar el cambio y marca el canvas dirty. Compone cdp_eval.
|
||||
|
||||
Funcion impura: hace red (CDP WebSocket) y muta el grafo de la UI.
|
||||
"""
|
||||
import json
|
||||
|
||||
try: # ejecucion directa del archivo / fn run (browser/ en sys.path[0])
|
||||
from cdp_eval import cdp_eval
|
||||
except ImportError: # importado como paquete (sys.path = python/functions)
|
||||
from browser.cdp_eval import cdp_eval
|
||||
|
||||
|
||||
def comfyui_set_node_widget_ui(
|
||||
node: str,
|
||||
widget_name: str,
|
||||
value,
|
||||
*,
|
||||
match: str = "type",
|
||||
port: int = 9222,
|
||||
server_url_substr: str = "8188",
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Asigna el valor de un widget de un nodo del grafo en vivo.
|
||||
|
||||
Args:
|
||||
node: identificador del nodo a localizar, interpretado segun `match`.
|
||||
widget_name: nombre del widget a editar (ej. "text", "steps", "seed",
|
||||
"cfg", "sampler_name").
|
||||
value: nuevo valor (str, int, float o bool). Se serializa a JSON.
|
||||
match: criterio de busqueda del nodo. "type" (por comfyClass/type, ej.
|
||||
"CLIPTextEncode" o "KSampler"), "id" (por n.id) o "title" (por el
|
||||
titulo visible del nodo). Default "type".
|
||||
port: puerto de remote debugging del Chrome diario. Default 9222.
|
||||
server_url_substr: substring de la URL de la pestana de ComfyUI.
|
||||
timeout_s: timeout de la conexion CDP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok, matched_nodes (int), set (bool), old_value, new_value, error}.
|
||||
Si `match="type"` produce varios nodos, actua sobre el primero y reporta
|
||||
cuantos coincidieron en matched_nodes.
|
||||
"""
|
||||
expr = (
|
||||
"(function(){"
|
||||
" if(!window.app || !app.graph) return {matched_nodes:0, set:false, error:'window.app.graph no disponible'};"
|
||||
" var nodes = app.graph._nodes || [];"
|
||||
f" var key = {json.dumps(match)};"
|
||||
f" var target = {json.dumps(node)};"
|
||||
f" var wname = {json.dumps(widget_name)};"
|
||||
f" var nval = {json.dumps(value)};"
|
||||
" var matches = nodes.filter(function(n){"
|
||||
" if(key==='id') return String(n.id)===String(target);"
|
||||
" if(key==='title') return n.title===target;"
|
||||
" return (n.comfyClass||n.type)===target;"
|
||||
" });"
|
||||
" if(matches.length===0) return {matched_nodes:0, set:false, error:'sin nodo que matchee '+key+'='+target};"
|
||||
" var n = matches[0];"
|
||||
" var w = (n.widgets||[]).find(function(x){return x.name===wname;});"
|
||||
" if(!w) return {matched_nodes:matches.length, set:false, error:'el nodo no tiene widget \"'+wname+'\"'};"
|
||||
" var old = w.value;"
|
||||
" w.value = nval;"
|
||||
" if(typeof w.callback==='function'){ try{ w.callback(nval); }catch(e){} }"
|
||||
" if(typeof app.graph.setDirtyCanvas==='function') app.graph.setDirtyCanvas(true,true);"
|
||||
" return {matched_nodes:matches.length, set:true, old_value:old, new_value:w.value, error:''};"
|
||||
"})()"
|
||||
)
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=server_url_substr,
|
||||
await_promise=False,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if not r["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"matched_nodes": 0,
|
||||
"set": False,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"error": r["error"],
|
||||
}
|
||||
val = r["value"] or {}
|
||||
return {
|
||||
"ok": bool(val.get("set")),
|
||||
"matched_nodes": val.get("matched_nodes", 0),
|
||||
"set": bool(val.get("set")),
|
||||
"old_value": val.get("old_value"),
|
||||
"new_value": val.get("new_value"),
|
||||
"error": val.get("error", ""),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = comfyui_set_node_widget_ui(
|
||||
"KSampler", "steps", 25, match="type"
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
|
||||
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
|
||||
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
|
||||
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
|
||||
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||
@@ -24,7 +24,7 @@ params:
|
||||
- name: pages
|
||||
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
|
||||
desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
|
||||
- name: timeout_s
|
||||
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
|
||||
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
|
||||
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
|
||||
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
|
||||
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
|
||||
|
||||
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||
# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9334 25
|
||||
|
||||
# Produccion (chromium-personal, port 9222 por defecto):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9222 20
|
||||
# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
|
||||
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
|
||||
--category it-programming --language es --port 9222
|
||||
--category it-programming --language es --port 9334
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
|
||||
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
|
||||
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||
- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
|
||||
perfil headless dedicado del scraping, que levanta/cierra el wrapper
|
||||
`monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
|
||||
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
|
||||
sirve para smoke interactivo. Sin Chrome escuchando devuelve
|
||||
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
|
||||
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
|
||||
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
|
||||
|
||||
@@ -198,7 +198,7 @@ def scrape_workana_projects(
|
||||
language: str = "es",
|
||||
extra_query: str = "",
|
||||
pages: int = 1,
|
||||
port: int = 9222,
|
||||
port: int = 9334,
|
||||
timeout_s: float = 20.0,
|
||||
) -> dict:
|
||||
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
|
||||
@@ -217,9 +217,12 @@ def scrape_workana_projects(
|
||||
filtrar por palabra clave (ej. "python", "scraping").
|
||||
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
|
||||
adicional se navega con &page=N.
|
||||
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
|
||||
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
|
||||
port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
|
||||
perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
|
||||
levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
|
||||
9222 por defecto: ese es el chromium-personal del usuario y el scraping
|
||||
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
|
||||
recon) tambien sirve 9333 (el del browser_mcp).
|
||||
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
|
||||
el polling de aparicion de cards. Default 20.0.
|
||||
|
||||
@@ -293,7 +296,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--language", default="es")
|
||||
parser.add_argument("--extra-query", default="")
|
||||
parser.add_argument("--pages", type=int, default=1)
|
||||
parser.add_argument("--port", type=int, default=9222)
|
||||
parser.add_argument("--port", type=int, default=9334)
|
||||
parser.add_argument("--timeout-s", type=float, default=20.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
|
||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
|
||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
|
||||
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -25,7 +25,9 @@ params:
|
||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
||||
- name: max_dim
|
||||
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||
- name: mask
|
||||
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -81,3 +83,14 @@ suavizar el relieve.
|
||||
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
||||
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
||||
`estimate_image_depth`.
|
||||
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
|
||||
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
|
||||
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
|
||||
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
|
||||
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
|
||||
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
|
||||
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
|
||||
|
||||
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
|
||||
out_glb_path: str,
|
||||
z_scale: float = 0.35,
|
||||
max_dim: int = 220,
|
||||
mask: "np.ndarray | None" = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
|
||||
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
||||
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
||||
Default 220 (~48k vértices, ~96k caras).
|
||||
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
|
||||
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
|
||||
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
||||
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
|
||||
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
||||
H, W = depth.shape
|
||||
|
||||
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
|
||||
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
|
||||
fg = None
|
||||
if mask is not None:
|
||||
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
|
||||
fg = np.asarray(mask_img) >= 128
|
||||
depth = np.where(fg, depth, 0.0).astype(np.float32)
|
||||
|
||||
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
||||
aspect = W / float(H)
|
||||
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
||||
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
|
||||
]
|
||||
)
|
||||
|
||||
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
|
||||
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
|
||||
if fg is not None:
|
||||
keep = fg.ravel()[faces].all(axis=1)
|
||||
faces = faces[keep]
|
||||
|
||||
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
||||
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
||||
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: remove_background
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
|
||||
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
|
||||
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
|
||||
- name: engine
|
||||
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
|
||||
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/remove_background.py"
|
||||
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
|
||||
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
|
||||
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from remove_background import remove_background
|
||||
|
||||
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
|
||||
assert res["status"] == "ok"
|
||||
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
|
||||
print(res["height"], res["width"]) # p.ej. 1024 768
|
||||
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
|
||||
assert 0.0 < res["fg_fraction"] < 1.0
|
||||
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
|
||||
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
|
||||
```
|
||||
|
||||
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
|
||||
|
||||
```bash
|
||||
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
|
||||
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
|
||||
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
|
||||
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
|
||||
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
|
||||
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
|
||||
catalogos, datasets, miniaturas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
|
||||
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
|
||||
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
|
||||
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
|
||||
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
|
||||
interprete muere.
|
||||
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
|
||||
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
|
||||
status error (motor no instalado, fallo, o mascara degenerada).
|
||||
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
|
||||
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
|
||||
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
|
||||
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
|
||||
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
|
||||
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
|
||||
esa eleccion e ignora el alfa existente.
|
||||
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
|
||||
`from remove_background import remove_background`), NO `from datascience import ...`. El
|
||||
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
|
||||
funcion que romperian el import del paquete en el venv de vision.
|
||||
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
|
||||
|
||||
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
|
||||
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
|
||||
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
|
||||
neutro.
|
||||
|
||||
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
|
||||
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
|
||||
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
|
||||
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Fondo gris neutro sobre el que se compone el objeto recortado.
|
||||
NEUTRAL_BG = (127, 127, 127)
|
||||
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
|
||||
_ALPHA_THRESH = 128
|
||||
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
|
||||
_REMBG_SESSION = None
|
||||
|
||||
|
||||
def _existing_alpha_mask(image):
|
||||
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
|
||||
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
||||
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
|
||||
if alpha.min() < _ALPHA_THRESH:
|
||||
return alpha
|
||||
return None
|
||||
|
||||
|
||||
def _composite_over_neutral(image_rgb, mask):
|
||||
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
|
||||
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
|
||||
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
|
||||
bg = np.empty_like(rgb)
|
||||
bg[:] = NEUTRAL_BG
|
||||
out = rgb * alpha + bg * (1.0 - alpha)
|
||||
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
|
||||
|
||||
def _remove_with_rembg(image):
|
||||
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
|
||||
global _REMBG_SESSION
|
||||
from rembg import new_session, remove
|
||||
|
||||
if _REMBG_SESSION is None:
|
||||
_REMBG_SESSION = new_session("u2net")
|
||||
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
|
||||
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
|
||||
return mask, "rembg:u2net"
|
||||
|
||||
|
||||
def _remove_with_grabcut(image):
|
||||
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
|
||||
import cv2
|
||||
|
||||
rgb = np.asarray(image.convert("RGB"))
|
||||
h, w = rgb.shape[:2]
|
||||
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||||
gc_mask = np.zeros((h, w), np.uint8)
|
||||
bgd_model = np.zeros((1, 65), np.float64)
|
||||
fgd_model = np.zeros((1, 65), np.float64)
|
||||
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
|
||||
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
|
||||
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
|
||||
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
||||
return fg, "opencv:grabcut"
|
||||
|
||||
|
||||
def _remove_with_threshold(image):
|
||||
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
|
||||
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
|
||||
h, w = rgb.shape[:2]
|
||||
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
|
||||
bg_color = border.mean(axis=0)
|
||||
dist = np.linalg.norm(rgb - bg_color, axis=2)
|
||||
thresh = max(30.0, float(dist.mean()))
|
||||
fg = (dist > thresh).astype(np.uint8) * 255
|
||||
return fg, "threshold:border"
|
||||
|
||||
|
||||
def remove_background(image_path: str, engine: str = "auto") -> dict:
|
||||
"""
|
||||
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
|
||||
|
||||
Parámetros:
|
||||
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
|
||||
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
|
||||
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
|
||||
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
|
||||
o la máscara resulta degenerada, se devuelve status error.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
|
||||
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
|
||||
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
|
||||
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
|
||||
redondeada a 4 decimales)}.
|
||||
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
|
||||
no disponible/fallido, o ningún motor produjo una máscara válida).
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
|
||||
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
|
||||
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
|
||||
if engine == "auto":
|
||||
existing = _existing_alpha_mask(image)
|
||||
if existing is not None:
|
||||
composed = _composite_over_neutral(image, existing)
|
||||
frac = float((existing >= 128).mean())
|
||||
h, w = existing.shape[:2]
|
||||
return {
|
||||
"status": "ok",
|
||||
"image": composed,
|
||||
"mask": existing,
|
||||
"engine": "passthrough:alpha",
|
||||
"height": int(h),
|
||||
"width": int(w),
|
||||
"fg_fraction": round(frac, 4),
|
||||
}
|
||||
|
||||
# Construir la lista de motores a probar según el engine pedido.
|
||||
if engine == "auto":
|
||||
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
|
||||
elif engine == "rembg":
|
||||
attempts = [_remove_with_rembg]
|
||||
elif engine == "grabcut":
|
||||
attempts = [_remove_with_grabcut]
|
||||
elif engine == "threshold":
|
||||
attempts = [_remove_with_threshold]
|
||||
else:
|
||||
attempts = []
|
||||
|
||||
if not attempts:
|
||||
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
|
||||
|
||||
last_exc = None
|
||||
for attempt in attempts:
|
||||
try:
|
||||
mask, used = attempt(image)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_exc = e
|
||||
continue
|
||||
|
||||
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
|
||||
frac = float((mask >= 128).mean())
|
||||
if frac < 0.01 or frac > 0.995:
|
||||
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
|
||||
continue
|
||||
|
||||
composed = _composite_over_neutral(image, mask)
|
||||
h, w = mask.shape[:2]
|
||||
return {
|
||||
"status": "ok",
|
||||
"image": composed,
|
||||
"mask": mask,
|
||||
"engine": used,
|
||||
"height": int(h),
|
||||
"width": int(w),
|
||||
"fg_fraction": round(frac, 4),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
|
||||
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
|
||||
sys.exit(1)
|
||||
|
||||
path = sys.argv[1]
|
||||
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
|
||||
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
res = remove_background(path, engine=eng)
|
||||
if res["status"] == "ok":
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"engine": res["engine"],
|
||||
"height": res["height"],
|
||||
"width": res["width"],
|
||||
"fg_fraction": res["fg_fraction"],
|
||||
}
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
rgb_path = os.path.join(out_dir, "rgb.png")
|
||||
mask_path = os.path.join(out_dir, "mask.png")
|
||||
res["image"].save(rgb_path)
|
||||
Image.fromarray(res["mask"]).save(mask_path)
|
||||
summary["rgb_path"] = rgb_path
|
||||
summary["mask_path"] = mask_path
|
||||
print(json.dumps(summary))
|
||||
else:
|
||||
print(json.dumps(res))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: mssql_connect
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def mssql_connect(host: str, database: str, user: str, password: str, port: int = 1433, login_timeout: int = 15, query_timeout: int = 30) -> pymssql.Connection"
|
||||
description: "Abre una conexion pymssql a un Microsoft SQL Server (donde corre Navision). Las credenciales llegan siempre por argumento (el caller las saca de pass/env), nunca hardcodeadas. login_timeout acota la fase de conexion/login para evitar cuelgues con un host inalcanzable. Devuelve el objeto conexion pymssql para iterar queries despues."
|
||||
tags: [mssql, sqlserver, navision, sql-connect, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [pymssql]
|
||||
params:
|
||||
- name: host
|
||||
desc: "Host o IP del servidor SQL Server. Desde WSL2 debe ser la IP LAN de Windows (ej. 10.0.0.5), no localhost."
|
||||
- name: database
|
||||
desc: "Nombre de la base de datos a la que conectar (ej. navdb)."
|
||||
- name: user
|
||||
desc: "Usuario de login de SQL Server (ej. sa)."
|
||||
- name: password
|
||||
desc: "Contrasena del usuario de login. Se pasa desde pass/env, nunca como literal."
|
||||
- name: port
|
||||
desc: "Puerto TCP del SQL Server. Por defecto 1433. La funcion lo convierte a string porque pymssql lo exige asi."
|
||||
- name: login_timeout
|
||||
desc: "Segundos permitidos para la fase de conexion/login antes de fallar. Por defecto 15. Evita que un host inalcanzable cuelgue indefinidamente."
|
||||
- name: query_timeout
|
||||
desc: "Segundos permitidos para cada query ejecutada sobre la conexion devuelta antes de hacer timeout. Por defecto 30."
|
||||
output: "Un objeto pymssql.Connection abierto. El caller es responsable de cerrarlo con .close() al terminar."
|
||||
tested: true
|
||||
tests: ["test_golden_connect_passes_string_port_and_kwargs", "test_error_path_wraps_failure_with_host"]
|
||||
test_file_path: "python/functions/infra/mssql_connect_test.py"
|
||||
file_path: "python/functions/infra/mssql_connect.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
|
||||
from infra.mssql_connect import mssql_connect
|
||||
|
||||
# La IP debe ser la IP LAN del servidor Windows: desde WSL2 "localhost" NO
|
||||
# llega al host Windows. La contrasena llega del entorno, nunca literal.
|
||||
conn = mssql_connect(
|
||||
host="10.0.0.5",
|
||||
database="navdb",
|
||||
user="sa",
|
||||
password=os.environ["MSSQL_PASSWORD"],
|
||||
port=1433,
|
||||
login_timeout=15,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT TOP 1 name FROM sys.databases")
|
||||
print(cur.fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites abrir una conexion a un Microsoft SQL Server (donde
|
||||
corre Navision) antes de iterar queries con `mssql_query`. Es el primer paso
|
||||
de cualquier pipeline que lea datos de Navision: abre la conexion una vez,
|
||||
reutilizala para varias queries, y cierrala al final. Triggers: "conecta a
|
||||
Navision", "lee de SQL Server", "abre conexion mssql".
|
||||
|
||||
## Gotchas
|
||||
|
||||
- WSL2 -> Windows: usa la IP LAN del servidor Windows, NUNCA `localhost`. Desde dentro de WSL2 `localhost` no alcanza el host Windows (el reenvio de localhost solo funciona Windows -> WSL, no al reves).
|
||||
- pymssql necesita el puerto como string. La funcion ya convierte `port` a `str(port)` internamente, asi que tu pasas un int normal.
|
||||
- `login_timeout` esta acotado (15s por defecto) precisamente para que un host inalcanzable o mal configurado falle con un RuntimeError claro en vez de colgarse indefinidamente. Ajustalo si la red es lenta, pero no lo dejes sin limite.
|
||||
- Credenciales NUNCA hardcodeadas: `user`/`password` llegan por argumento desde `pass`/env. No las escribas literales en el codigo del caller.
|
||||
- Cierra la conexion con `.close()` al terminar (idealmente en un `finally`). La funcion devuelve un handle abierto y no gestiona su ciclo de vida.
|
||||
- Requiere `pymssql` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Open a connection to a Microsoft SQL Server (Navision) via pymssql."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def mssql_connect(host: str, database: str, user: str, password: str,
|
||||
port: int = 1433, login_timeout: int = 15,
|
||||
query_timeout: int = 30):
|
||||
"""Open a connection to a Microsoft SQL Server instance (e.g. Navision).
|
||||
|
||||
Uses the pymssql driver. Credentials are always supplied by the caller
|
||||
(typically read from `pass`/env) and never hardcoded. The connection is
|
||||
impure I/O: it touches the network and the database server.
|
||||
|
||||
pymssql expects the TCP port as a string, so `port` is converted before
|
||||
being passed through. `login_timeout` bounds the connect/login phase, which
|
||||
is what keeps an invalid host from hanging indefinitely; `query_timeout`
|
||||
bounds individual queries run on the resulting connection.
|
||||
|
||||
Args:
|
||||
host: SQL Server host or IP. From WSL2 this must be the Windows LAN IP
|
||||
(e.g. "10.0.0.5"), not "localhost" — localhost does not reach the
|
||||
Windows host from inside WSL2.
|
||||
database: Name of the database to connect to (e.g. "navdb").
|
||||
user: SQL Server login user (e.g. "sa").
|
||||
password: Password for the login user. Pass it from `pass`/env, never
|
||||
as a string literal.
|
||||
port: TCP port of the SQL Server instance. Defaults to 1433. Converted
|
||||
to a string internally because pymssql requires a string port.
|
||||
login_timeout: Seconds allowed for the connect/login phase before it
|
||||
fails. Defaults to 15. Keeps an unreachable host from hanging.
|
||||
query_timeout: Seconds allowed for each query executed on the returned
|
||||
connection before it times out. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
An open pymssql.Connection. The caller is responsible for closing it
|
||||
with `.close()` when done.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pymssql is not installed, or if the connection/login
|
||||
fails. The message includes host:port and database for context and
|
||||
the original exception is chained for debugging.
|
||||
"""
|
||||
# Lazy import so the module loads even without pymssql installed.
|
||||
try:
|
||||
import pymssql
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
raise RuntimeError(
|
||||
"pymssql is required for mssql_connect; install pymssql"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return pymssql.connect(
|
||||
server=host,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
port=str(port),
|
||||
login_timeout=login_timeout,
|
||||
timeout=query_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"mssql_connect failed connecting to {host}:{port}/{database}: {exc}"
|
||||
) from exc
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for mssql_connect (mock-based, no real SQL Server)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from mssql_connect import mssql_connect
|
||||
|
||||
|
||||
def test_golden_connect_passes_string_port_and_kwargs(monkeypatch):
|
||||
"""Golden path: returns the driver connection and forwards the right kwargs.
|
||||
|
||||
The TCP port must reach pymssql as a STRING, and login_timeout must default
|
||||
to 15 when not supplied.
|
||||
"""
|
||||
captured: dict = {}
|
||||
sentinel = object()
|
||||
|
||||
def fake_connect(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr("pymssql.connect", fake_connect)
|
||||
|
||||
result = mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
|
||||
|
||||
assert result is sentinel
|
||||
assert captured["server"] == "10.0.0.5"
|
||||
assert captured["database"] == "navdb"
|
||||
assert captured["user"] == "sa"
|
||||
assert captured["password"] == "pw"
|
||||
assert captured["port"] == "1433"
|
||||
assert isinstance(captured["port"], str)
|
||||
assert captured["login_timeout"] == 15
|
||||
assert captured["timeout"] == 30
|
||||
|
||||
|
||||
def test_error_path_wraps_failure_with_host(monkeypatch):
|
||||
"""Error path: a driver failure becomes a clear RuntimeError, not a hang.
|
||||
|
||||
The wrapped message must include the host and the phrase 'failed connecting'
|
||||
so callers can diagnose connectivity problems.
|
||||
"""
|
||||
def fake_connect(**kwargs):
|
||||
raise Exception("login timeout")
|
||||
|
||||
monkeypatch.setattr("pymssql.connect", fake_connect)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "10.0.0.5" in message
|
||||
assert "failed connecting" in message
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: mssql_query
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict"
|
||||
description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas."
|
||||
tags: [mssql, sqlserver, navision, sql-connect, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"]
|
||||
test_file_path: "python/functions/infra/mssql_query_test.py"
|
||||
params:
|
||||
- name: conn
|
||||
desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()."
|
||||
- name: sql
|
||||
desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular."
|
||||
- name: params
|
||||
desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)."
|
||||
- name: max_rows
|
||||
desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)."
|
||||
output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))."
|
||||
file_path: "python/functions/infra/mssql_query.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.mssql_connect import mssql_connect
|
||||
from infra.mssql_query import mssql_query
|
||||
|
||||
conn = mssql_connect(
|
||||
host="10.0.0.5", database="navdb", user="readonly", password="<desde pass>"
|
||||
)
|
||||
try:
|
||||
res = mssql_query(
|
||||
conn,
|
||||
"SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
|
||||
("CLI-0001",),
|
||||
)
|
||||
print(res["columns"]) # ['No_', 'Amount']
|
||||
print(res["row_count"]) # numero de filas devueltas
|
||||
for fila in res["rows"]:
|
||||
print(fila["No_"], fila["Amount"])
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar
|
||||
consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada
|
||||
una. Pasa los valores variables como `params` para que el driver los vincule de
|
||||
forma segura (sin inyeccion) en lugar de construir el SQL con f-strings.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado),
|
||||
NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla.
|
||||
- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%`
|
||||
dentro del SQL: interpolar abre la puerta a inyeccion SQL.
|
||||
- No hace commit: es read-only, pensada para SELECT.
|
||||
- No cierra la conexion — la gestiona el caller (abrir una vez, consultar
|
||||
muchas, cerrar al final).
|
||||
- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`.
|
||||
- Si la sentencia no produce result set (`cursor.description is None`),
|
||||
`columns` y `rows` vuelven como listas vacias en lugar de fallar.
|
||||
- El mensaje de error es generico a proposito: no incluye el SQL ni los params
|
||||
para no filtrar datos sensibles.
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Run a parameterized SELECT over an open pymssql (SQL Server / Navision) connection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict:
|
||||
"""Execute a SELECT on an already-open connection and map rows to dicts.
|
||||
|
||||
The connection is supplied by the caller (typically from `mssql_connect`),
|
||||
so a single connection can be opened once and reused for many queries. This
|
||||
function never opens or closes the connection — it only borrows it. It is
|
||||
impure I/O: it touches the database over an existing connection.
|
||||
|
||||
Parameter binding is delegated to the driver: `params` is passed straight to
|
||||
`cursor.execute(sql, params)`. NEVER interpolate values into `sql` with
|
||||
f-strings or `%` formatting — that opens the door to SQL injection. Use the
|
||||
pymssql placeholders `%s` (positional) or `%(name)s` (named) in `sql` and
|
||||
let the driver bind safely. When `params is None`, the SQL is executed with
|
||||
no bound parameters.
|
||||
|
||||
The query runs read-only: no commit is issued. The cursor opened here is
|
||||
always closed before returning (try/finally), even on error.
|
||||
|
||||
Args:
|
||||
conn: An open connection object (e.g. the one returned by
|
||||
`mssql_connect`). Used by duck typing via `conn.cursor()`, so the
|
||||
concrete driver does not matter and the function stays testable.
|
||||
sql: The SELECT statement, using pymssql placeholders `%s` (positional)
|
||||
or `%(name)s` (named) for any bound values.
|
||||
params: A tuple/list for positional placeholders, a dict for named
|
||||
placeholders, or None for a query with no parameters. Passed to
|
||||
`cursor.execute(sql, params)` for safe driver-side binding.
|
||||
max_rows: If a positive int, only the first `max_rows` rows are fetched
|
||||
(via `cursor.fetchmany(max_rows)`). If None, all rows are fetched
|
||||
(via `cursor.fetchall()`).
|
||||
|
||||
Returns:
|
||||
A dict with three keys:
|
||||
- "columns": list of column names in result order (empty list if the
|
||||
statement produced no result set, i.e. `cursor.description is None`).
|
||||
- "rows": list of dicts, one per row, mapping each column name to its
|
||||
value. Empty list when the query returned no rows.
|
||||
- "row_count": int, equal to `len(rows)`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If executing or fetching the query fails. The message is
|
||||
deliberately generic (it does not include the SQL or the params,
|
||||
which may carry sensitive data) and the original exception is
|
||||
chained for debugging.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
if params is None:
|
||||
cur.execute(sql)
|
||||
else:
|
||||
cur.execute(sql, params)
|
||||
|
||||
description = cur.description
|
||||
if description is None:
|
||||
columns: list = []
|
||||
raw_rows: list = []
|
||||
else:
|
||||
columns = [d[0] for d in description]
|
||||
if max_rows is not None and max_rows > 0:
|
||||
raw_rows = cur.fetchmany(max_rows)
|
||||
else:
|
||||
raw_rows = cur.fetchall()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"mssql_query failed executing query: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
rows = [dict(zip(columns, row)) for row in raw_rows]
|
||||
return {"columns": columns, "rows": rows, "row_count": len(rows)}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests para mssql_query usando un doble de prueba (sin servidor real)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.mssql_query import mssql_query
|
||||
|
||||
|
||||
def _desc(*names):
|
||||
"""Construye una description estilo DB-API: una tupla 7-elem por columna."""
|
||||
return [(name, None, None, None, None, None, None) for name in names]
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""Doble de prueba de un cursor DB-API (pymssql-like)."""
|
||||
|
||||
def __init__(self, description=None, rows=None):
|
||||
self.description = description
|
||||
self._rows = list(rows or [])
|
||||
self.executed = None # (sql, params) de la ultima execute
|
||||
self.fetchmany_calls = [] # tamaños pedidos a fetchmany
|
||||
self.closed = False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.executed = (sql, params)
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
def fetchmany(self, size):
|
||||
self.fetchmany_calls.append(size)
|
||||
return list(self._rows[:size])
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeConn:
|
||||
"""Doble de prueba de una conexion: devuelve un FakeCursor fijo."""
|
||||
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
|
||||
def test_golden_maps_rows_to_dicts():
|
||||
cur = FakeCursor(
|
||||
description=_desc("No_", "Amount"),
|
||||
rows=[("CLI-1", 100), ("CLI-2", 200)],
|
||||
)
|
||||
conn = FakeConn(cur)
|
||||
|
||||
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera")
|
||||
|
||||
assert result == {
|
||||
"columns": ["No_", "Amount"],
|
||||
"rows": [
|
||||
{"No_": "CLI-1", "Amount": 100},
|
||||
{"No_": "CLI-2", "Amount": 200},
|
||||
],
|
||||
"row_count": 2,
|
||||
}
|
||||
assert cur.closed is True
|
||||
|
||||
|
||||
def test_binding_passes_params_to_driver():
|
||||
cur = FakeCursor(description=_desc("No_"), rows=[("CLI-0001",)])
|
||||
conn = FakeConn(cur)
|
||||
sql = "SELECT No_ FROM Cartera WHERE [Customer No_] = %s"
|
||||
|
||||
mssql_query(conn, sql, params=("CLI-0001",))
|
||||
|
||||
# El SQL y los params llegan al driver tal cual: binding, no interpolacion.
|
||||
assert cur.executed == (sql, ("CLI-0001",))
|
||||
|
||||
|
||||
def test_zero_rows_no_error():
|
||||
cur = FakeCursor(description=_desc("No_", "Amount"), rows=[])
|
||||
conn = FakeConn(cur)
|
||||
|
||||
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera WHERE 1 = 0")
|
||||
|
||||
assert result["rows"] == []
|
||||
assert result["row_count"] == 0
|
||||
assert result["columns"] == ["No_", "Amount"]
|
||||
|
||||
|
||||
def test_max_rows_uses_fetchmany():
|
||||
cur = FakeCursor(
|
||||
description=_desc("No_"),
|
||||
rows=[("CLI-1",), ("CLI-2",), ("CLI-3",)],
|
||||
)
|
||||
conn = FakeConn(cur)
|
||||
|
||||
result = mssql_query(conn, "SELECT No_ FROM Cartera", max_rows=1)
|
||||
|
||||
assert cur.fetchmany_calls == [1]
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"] == [{"No_": "CLI-1"}]
|
||||
|
||||
|
||||
def test_description_none_empty_columns():
|
||||
cur = FakeCursor(description=None, rows=[])
|
||||
conn = FakeConn(cur)
|
||||
|
||||
result = mssql_query(conn, "SET NOCOUNT ON")
|
||||
|
||||
assert result["columns"] == []
|
||||
assert result["rows"] == []
|
||||
assert result["row_count"] == 0
|
||||
|
||||
|
||||
def test_execution_error_raises_runtimeerror():
|
||||
class BoomCursor(FakeCursor):
|
||||
def execute(self, sql, params=None):
|
||||
raise ValueError("boom")
|
||||
|
||||
cur = BoomCursor()
|
||||
conn = FakeConn(cur)
|
||||
|
||||
with pytest.raises(RuntimeError, match="mssql_query failed executing query"):
|
||||
mssql_query(conn, "SELECT 1")
|
||||
|
||||
# El cursor se cierra incluso en error (try/finally).
|
||||
assert cur.closed is True
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: comfyui_batch_generate
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_batch_generate(workflow: dict, *, seeds: list | None = None, server: str = \"127.0.0.1:8188\") -> dict"
|
||||
description: "Encola N variantes de un workflow ComfyUI, una por seed de la lista, parcheando el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced/SamplerCustom.noise_seed) sin mutar el original (deepcopy), y recoge cada prompt_id. Compone comfyui_submit_workflow. Util para barridos de re-roll: misma escena, varias semillas, una sola llamada. Devuelve {ok, prompt_ids, count, error}. Impura: HTTP POST por variante, solo stdlib."
|
||||
tags: [comfyui, ml, batch, seeds, queue, http]
|
||||
uses_functions: ["comfyui_submit_workflow_py_ml"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: workflow
|
||||
desc: "dict en API format (resultado de un builder). No se muta: cada variante es una copia profunda con la semilla parcheada."
|
||||
- name: seeds
|
||||
desc: "Lista de semillas (int); cada una produce una variante encolada. None o vacia encola el workflow tal cual una sola vez. keyword-only."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188'). keyword-only."
|
||||
output: "dict con ok (bool, True si TODAS las variantes se encolaron), prompt_ids (list[str] en orden de seeds, para comfyui_wait_result), count (int, variantes encoladas con exito), error (str, primer error; vacio si OK). Si una variante falla, detiene el barrido y devuelve los prompt_ids ya encolados."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_batch_generate.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
from ml.comfyui_batch_generate import comfyui_batch_generate
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
)
|
||||
res = comfyui_batch_generate(wf, seeds=[1, 2, 3])
|
||||
# {'ok': True, 'prompt_ids': ['<id1>', '<id2>', '<id3>'], 'count': 3, 'error': ''}
|
||||
for pid in res["prompt_ids"]:
|
||||
pass # comfyui_wait_result(pid) para recoger cada resultado
|
||||
```
|
||||
|
||||
O lanzable directo (build txt2img + encolar 2 seeds) con: `./fn run comfyui_batch_generate`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para generar varias variantes de la misma escena cambiando solo la semilla
|
||||
(re-roll de calidad) en una sola llamada, en vez de editar el seed y reenviar a
|
||||
mano N veces. Aplica a cualquier workflow con nodo sampler: txt2img, img2img,
|
||||
video (parchea `noise_seed` del SamplerCustom de LTX), etc. Tras encolar, sigue
|
||||
cada `prompt_id` con `comfyui_wait_result`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Parchea TODO input llamado `seed` o `noise_seed` en cualquier nodo. Si un
|
||||
workflow tiene varios samplers, todos reciben la misma semilla de la variante
|
||||
(normalmente lo deseado). Si necesitas semillas independientes por sampler,
|
||||
parchea a mano.
|
||||
- Encolar tiene efecto secundario: arranca trabajo de GPU. N seeds = N prompts en
|
||||
cola = N corridas de GPU en serie. En 8GB, no encoles 20 videos a la vez sin
|
||||
vigilar VRAM/tiempo.
|
||||
- `seeds=None` encola el workflow tal cual UNA vez (sin tocar la semilla): util
|
||||
como "submit con la firma de batch".
|
||||
- Fail-fast: si una variante es rechazada (HTTP 400), detiene el barrido,
|
||||
devuelve `ok=False` + `error` y los `prompt_ids` ya encolados (no hace rollback
|
||||
de los anteriores — ya estan en la cola del servidor).
|
||||
- Si necesitas cortar un barrido a medias, usa `comfyui_interrupt_queue` (corta el
|
||||
que se ejecuta) o `POST /queue {"clear": true}` para vaciar los pendientes.
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Encola N variantes de un workflow ComfyUI, una por seed, y recoge los prompt_ids.
|
||||
|
||||
Funcion impura: hace red (POST /prompt por variante, via comfyui_submit_workflow).
|
||||
Compone comfyui_submit_workflow.
|
||||
|
||||
Para cada seed de la lista, copia el workflow (deepcopy, no muta el original),
|
||||
parchea el campo de semilla de los nodos sampler (KSampler.seed, KSamplerAdvanced.
|
||||
noise_seed, SamplerCustom.noise_seed — en general cualquier input "seed"/"noise_seed")
|
||||
y lo encola. Util para barridos de re-roll: misma escena, varias semillas, una sola
|
||||
llamada. Devuelve los prompt_ids en el mismo orden que la lista de seeds; cada uno
|
||||
se sigue con comfyui_wait_result.
|
||||
"""
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402
|
||||
|
||||
# Campos de semilla conocidos en los nodos sampler de ComfyUI.
|
||||
_SEED_KEYS = ("seed", "noise_seed")
|
||||
|
||||
|
||||
def _patch_seed(workflow: dict, seed: int) -> dict:
|
||||
"""Copia el workflow y fija `seed` en todos los inputs de semilla (no muta el original)."""
|
||||
wf = copy.deepcopy(workflow)
|
||||
for node in wf.values():
|
||||
inputs = node.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
continue
|
||||
for key in _SEED_KEYS:
|
||||
if key in inputs:
|
||||
inputs[key] = seed
|
||||
return wf
|
||||
|
||||
|
||||
def comfyui_batch_generate(
|
||||
workflow: dict,
|
||||
*,
|
||||
seeds: list | None = None,
|
||||
server: str = "127.0.0.1:8188",
|
||||
) -> dict:
|
||||
"""Encola una variante del workflow por cada seed y devuelve los prompt_ids.
|
||||
|
||||
Args:
|
||||
workflow: dict en API format (resultado de un builder). No se muta: cada
|
||||
variante es una copia profunda con la semilla parcheada.
|
||||
seeds: lista de semillas (int). Cada una produce una variante encolada. Si
|
||||
es None o vacia, se encola el workflow tal cual una sola vez (sin
|
||||
parchear semilla). keyword-only.
|
||||
server: host:port del servidor ComfyUI sin esquema. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si TODAS las variantes se encolaron sin error.
|
||||
- prompt_ids (list[str]): prompt_id de cada variante encolada, en orden.
|
||||
- count (int): numero de variantes encoladas con exito.
|
||||
- error (str): primer error encontrado; cadena vacia si todo OK. Si una
|
||||
variante falla, se detiene el barrido y se devuelven los prompt_ids ya
|
||||
encolados.
|
||||
"""
|
||||
out = {"ok": False, "prompt_ids": [], "count": 0, "error": ""}
|
||||
variants = [(s, _patch_seed(workflow, s)) for s in seeds] if seeds else [(None, workflow)]
|
||||
|
||||
for seed, wf in variants:
|
||||
try:
|
||||
resp = comfyui_submit_workflow(wf, server=server)
|
||||
except RuntimeError as exc:
|
||||
label = "tal cual" if seed is None else f"seed={seed}"
|
||||
out["error"] = f"variante {label} fallo al encolar: {exc}"
|
||||
return out
|
||||
out["prompt_ids"].append(resp["prompt_id"])
|
||||
|
||||
out["count"] = len(out["prompt_ids"])
|
||||
out["ok"] = True
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
)
|
||||
res = comfyui_batch_generate(wf, seeds=[1, 2])
|
||||
print(f"ok={res['ok']} count={res['count']} ids={res['prompt_ids']} error={res['error']!r}")
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: comfyui_build_controlnet_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_controlnet_workflow(ckpt_name: str, control_image: str, cn_name: str, positive: str, negative: str = \"\", *, strength: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, width: int = 512, height: int = 512) -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI txt2img guiado por ControlNet en API format: CheckpointLoaderSimple + EmptyLatentImage + LoadImage (mapa de control) + ControlNetLoader -> ControlNetApply (inyecta el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, ml, image-generation, controlnet, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ckpt_name
|
||||
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
|
||||
- name: control_image
|
||||
desc: "Nombre del archivo de la imagen de control dentro de input/ del servidor (mapa canny/depth/openpose preprocesado); lo carga el nodo LoadImage."
|
||||
- name: cn_name
|
||||
desc: "Nombre del modelo ControlNet en models/controlnet/ tal como lo lista comfyui_object_info para ControlNetLoader (control_net_name)."
|
||||
- name: positive
|
||||
desc: "Prompt positivo: lo que se quiere ver en la imagen."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
|
||||
- name: strength
|
||||
desc: "Fuerza con la que el ControlNet condiciona la generacion (0.0 = nula, 1.0 = plena). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del latente/imagen en px (multiplo de 8). keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del latente/imagen en px (multiplo de 8). keyword-only."
|
||||
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', EmptyLatentImage '5', LoadImage '10', ControlNetLoader '12', CLIPTextEncode '6'/'7', ControlNetApply '13', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["usa ControlNetLoader+ControlNetApply", "control_image, modelo cn y strength reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_controlnet_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_controlnet_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_controlnet_workflow import comfyui_build_controlnet_workflow
|
||||
|
||||
wf = comfyui_build_controlnet_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
control_image="pose_canny.png", # mapa de control en input/
|
||||
cn_name="control_v11p_sd15_canny.pth", # modelo en models/controlnet/
|
||||
positive="a knight in shining armor, dramatic lighting",
|
||||
negative="blurry, low quality",
|
||||
strength=0.8,
|
||||
seed=42,
|
||||
)
|
||||
# wf["13"]["class_type"] == "ControlNetApply"
|
||||
# wf["13"]["inputs"]["conditioning"] == ["6", 0] # aplica sobre el positivo
|
||||
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler usa el cond condicionado
|
||||
```
|
||||
|
||||
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
|
||||
`*` keyword-only); usa el import o un heredoc.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras controlar la composicion de la imagen con una guia estructural
|
||||
(bordes canny, profundidad depth, pose openpose, scribble) en lugar de dejar la
|
||||
composicion al azar del prompt. Necesitas el mapa de control ya preprocesado en
|
||||
`input/` y el modelo ControlNet adecuado descargado en `models/controlnet/`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
|
||||
- `control_image` debe ser el mapa de control YA preprocesado (ej. salida de un
|
||||
preprocesador canny/depth). Este builder NO incluye el nodo preprocesador; si
|
||||
pasas una foto normal, el ControlNet la usara tal cual.
|
||||
- Usa el nodo clasico `ControlNetApply` (un solo `strength`). Para ControlNet
|
||||
avanzado con `start_percent`/`end_percent` necesitas `ControlNetApplyAdvanced`
|
||||
(no cubierto aqui): montalo en la UI y captura con `comfyui_export_workflow_ui`.
|
||||
- `cn_name` debe corresponder a la version del checkpoint (un ControlNet de SD1.5
|
||||
no sirve con un checkpoint SDXL). Valida antes con `comfyui_validate_workflow`.
|
||||
- Es pura: NO valida que los modelos existan en el servidor. Valida antes.
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Construye un workflow ComfyUI con ControlNet en API format (nodos numerados).
|
||||
|
||||
ControlNet condiciona la generacion con una imagen de control (canny, depth,
|
||||
pose, scribble, ...). Cadena de nodos: CheckpointLoaderSimple + EmptyLatentImage
|
||||
+ LoadImage (imagen de control) + ControlNetLoader -> ControlNetApply (inyecta
|
||||
el control sobre el condicionamiento positivo) -> KSampler -> VAEDecode ->
|
||||
SaveImage. Los CLIPTextEncode codifican el prompt positivo y el negativo.
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
|
||||
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_controlnet_workflow(
|
||||
ckpt_name: str,
|
||||
control_image: str,
|
||||
cn_name: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
strength: float = 1.0,
|
||||
steps: int = 20,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
width: int = 512,
|
||||
height: int = 512,
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow txt2img guiado por ControlNet.
|
||||
|
||||
Args:
|
||||
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
|
||||
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
|
||||
comfyui_object_info para CheckpointLoaderSimple.
|
||||
control_image: nombre del archivo de la imagen de control dentro de la
|
||||
carpeta input/ del servidor ComfyUI (lo carga el nodo LoadImage).
|
||||
Suele ser un mapa preprocesado (canny/depth/openpose).
|
||||
cn_name: nombre del modelo ControlNet en models/controlnet/ tal como lo
|
||||
lista comfyui_object_info para ControlNetLoader (control_net_name).
|
||||
positive: prompt positivo (lo que se quiere ver en la imagen).
|
||||
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
|
||||
strength: fuerza con la que el ControlNet condiciona la generacion
|
||||
(0.0 = nula, 1.0 = plena). keyword-only.
|
||||
steps: pasos de sampling del KSampler. keyword-only.
|
||||
cfg: classifier-free guidance scale. keyword-only.
|
||||
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
|
||||
keyword-only.
|
||||
width: ancho del latente/imagen en px (multiplo de 8). keyword-only.
|
||||
height: alto del latente/imagen en px (multiplo de 8). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids y cada valor tiene class_type + inputs.
|
||||
"""
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": control_image},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {"control_net_name": cn_name},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"13": {
|
||||
"class_type": "ControlNetApply",
|
||||
"inputs": {
|
||||
"conditioning": ["6", 0],
|
||||
"control_net": ["12", 0],
|
||||
"image": ["10", 0],
|
||||
"strength": strength,
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
"model": ["4", 0],
|
||||
"positive": ["13", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "comfy_controlnet", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_controlnet_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
control_image="pose_canny.png",
|
||||
cn_name="control_v11p_sd15_canny.pth",
|
||||
positive="a knight in shining armor, dramatic lighting",
|
||||
negative="blurry, low quality",
|
||||
strength=0.8,
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: comfyui_build_facedetailer_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_facedetailer_workflow(base_workflow_or_image, ckpt_name: str, positive: str, negative: str = \"\", *, bbox_model: str = \"face_yolov8m.pt\", denoise: float = 0.5, steps: int = 20, cfg: float = 8.0, seed: int = 0, guide_size: float = 512.0, bbox_threshold: float = 0.5, feather: int = 5, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"facedetail\") -> dict"
|
||||
description: "Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format: detecta caras con UltralyticsDetectorProvider (YOLO bbox) y las regenera con un sampler de difusion para recuperar detalle (el pain #1 de retratos). Acepta el nombre de una imagen ya en input/ (modo str) o un workflow base como dict (modo workflow, p.ej. el de comfyui_build_txt2img_workflow): en este caso toma la imagen del VAEDecode y reutiliza el CheckpointLoaderSimple. Class_types reales verificados en /object_info. Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, facedetailer, impact-pack, portrait, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: base_workflow_or_image
|
||||
desc: "Nombre (str) de una imagen ya en el input/ del servidor, o un workflow base (dict en API format). Con str monta LoadImage + CheckpointLoaderSimple nuevos; con dict toma la imagen del primer VAEDecode y reutiliza su CheckpointLoaderSimple."
|
||||
- name: ckpt_name
|
||||
desc: "Checkpoint para el sampler del detailer (y para el loader nuevo en modo imagen). Debe existir en el servidor (CheckpointLoaderSimple)."
|
||||
- name: positive
|
||||
desc: "Prompt positivo para regenerar las caras (ej. 'detailed face, sharp eyes, skin texture'). Se codifica con el CLIP del checkpoint."
|
||||
- name: negative
|
||||
desc: "Prompt negativo. Por defecto ''."
|
||||
- name: bbox_model
|
||||
desc: "Modelo de deteccion Ultralytics. Acepta nombre corto ('face_yolov8m.pt') o prefijado ('bbox/face_yolov8m.pt'); si no trae prefijo se asume 'bbox/'. keyword-only."
|
||||
- name: denoise
|
||||
desc: "Fuerza de re-difusion de cada cara (0.5 por defecto; mas alto = mas cambio, mas riesgo de perder identidad). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del detailer. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance del detailer. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del sampler del detailer. keyword-only."
|
||||
- name: guide_size
|
||||
desc: "Tamano (px) al que se reescala cada cara recortada antes de re-difundirla (FaceDetailer.guide_size). keyword-only."
|
||||
- name: bbox_threshold
|
||||
desc: "Umbral de confianza del detector de caras (0..1). Mas alto = menos falsos positivos, riesgo de no detectar caras pequenas. keyword-only."
|
||||
- name: feather
|
||||
desc: "Pixeles de difuminado del borde de la mascara al recomponer la cara sobre la imagen. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Sampler del detailer (ej. 'euler'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del detailer (ej. 'normal'). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. En modo dict contiene los nodos del workflow base mas los del detailer (node_ids prefijados 'fd_' para no colisionar); el SaveImage 'fd_save' produce la imagen con las caras regeneradas."
|
||||
tested: true
|
||||
tests: ["modo imagen monta UltralyticsDetectorProvider + FaceDetailer + SaveImage", "modo workflow reutiliza VAEDecode y CheckpointLoaderSimple del base y conserva sus nodos", "normaliza bbox_model corto a prefijo bbox/", "dict sin VAEDecode lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_facedetailer_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_facedetailer_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
# Modo workflow: genera un retrato y le aplica FaceDetailer en el mismo grafo.
|
||||
base = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="portrait of a woman, soft light",
|
||||
width=512, height=768, seed=7,
|
||||
)
|
||||
wf = comfyui_build_facedetailer_workflow(
|
||||
base,
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="detailed face, sharp eyes, skin texture",
|
||||
negative="blurry, deformed",
|
||||
denoise=0.45,
|
||||
)
|
||||
# wf["fd_det"]["class_type"] == "UltralyticsDetectorProvider"
|
||||
# wf["fd_det"]["inputs"]["model_name"] == "bbox/face_yolov8m.pt"
|
||||
# wf["fd_face"]["class_type"] == "FaceDetailer"
|
||||
# wf["fd_face"]["inputs"]["image"] == ["8", 0] # VAEDecode del base
|
||||
# wf["4"]["class_type"] == "CheckpointLoaderSimple" # nodos del base conservados
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_facedetailer_workflow` (imprime el JSON del workflow de ejemplo en modo imagen).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una imagen generada tiene caras mediocres (ojos borrosos, piel plana,
|
||||
rasgos deformados) y quieres regenerarlas con detalle sin rehacer toda la imagen.
|
||||
Es el ADetailer/FaceDetailer "pro" del flujo de retratos. Encadénala tras
|
||||
`comfyui_build_txt2img_workflow` (pásale el dict) para detail en una sola cola, o
|
||||
pásale el nombre de una imagen ya en `input/` para mejorar una imagen existente.
|
||||
Después: `comfyui_submit_workflow` → `comfyui_wait_result` → `comfyui_fetch_output_image`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados / con prefijo `fd_`), NO el formato de la UI.
|
||||
- Requiere **ComfyUI-Impact-Pack** instalado (provee `FaceDetailer` y
|
||||
`UltralyticsDetectorProvider`). Si el server responde HTTP 400 "node type not
|
||||
found: FaceDetailer", el custom node no está cargado: revísalo en el Manager.
|
||||
- El modelo de detección debe estar en `models/ultralytics/bbox/` (aquí
|
||||
`face_yolov8m.pt`). El nodo lo referencia con prefijo de subcarpeta
|
||||
(`bbox/face_yolov8m.pt`); la función normaliza el nombre corto automáticamente.
|
||||
- **No usa SAM** (segment-anything): `sam_model_opt` es opcional y aquí no hay
|
||||
modelo SAM instalado (`SAMLoader` reporta lista vacía). FaceDetailer funciona
|
||||
solo con el detector de bounding box, que basta para caras. Si instalas un SAM
|
||||
y quieres máscaras más finas, habría que añadir el `SAMLoader` aparte.
|
||||
- En **modo workflow** (dict) se reutiliza el primer `CheckpointLoaderSimple` y el
|
||||
primer `VAEDecode` del base. Si el base usa otro loader (p.ej. un flujo SDXL con
|
||||
loaders distintos), se monta un `CheckpointLoaderSimple` propio con `ckpt_name` —
|
||||
asegúrate de que el checkpoint case con el espacio latente del base.
|
||||
- El SaveImage del workflow base (si lo tenía) se conserva: el grafo produce tanto
|
||||
la imagen base como la "detailed" (`fd_save`). Si solo quieres la final, ignora
|
||||
la otra salida.
|
||||
- `denoise` alto (>0.6) puede cambiar la identidad de la cara; 0.4–0.5 conserva
|
||||
rasgos y añade detalle.
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Construye un workflow ComfyUI con FaceDetailer (Impact-Pack) en API format.
|
||||
|
||||
FaceDetailer es el nodo estrella de ComfyUI-Impact-Pack para el "pain #1" de los
|
||||
retratos: detecta las caras de una imagen (con un detector YOLO via
|
||||
UltralyticsDetectorProvider) y regenera cada una por separado con un sampler de
|
||||
difusion, recuperando detalle (ojos, piel, dientes) que el primer render pierde.
|
||||
|
||||
Esta funcion monta el sub-grafo del detailer y lo conecta a una fuente de imagen,
|
||||
que puede ser:
|
||||
|
||||
- una imagen ya subida al `input/` del servidor (pasa su nombre como str), o
|
||||
- un workflow base ya construido (pasa el dict, p.ej. el de
|
||||
`comfyui_build_txt2img_workflow`): el detailer toma la imagen del `VAEDecode`
|
||||
del workflow y reutiliza su `CheckpointLoaderSimple` (model/clip/vae).
|
||||
|
||||
Cadena del sub-grafo (sobre los class_types REALES de Impact-Pack, verificados en
|
||||
`/object_info`):
|
||||
|
||||
UltralyticsDetectorProvider -> BBOX_DETECTOR
|
||||
CheckpointLoaderSimple (nuevo o reutilizado) -> MODEL, CLIP, VAE
|
||||
CLIPTextEncode (positive, negative) -> CONDITIONING
|
||||
FaceDetailer(image, model, clip, vae, positive, negative, bbox_detector, ...) -> IMAGE
|
||||
SaveImage
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _normalize_bbox_model(name: str) -> str:
|
||||
"""Normaliza el nombre del modelo de deteccion al formato del nodo.
|
||||
|
||||
UltralyticsDetectorProvider expone los modelos con prefijo de subcarpeta
|
||||
(`bbox/face_yolov8m.pt`, `segm/person_yolov8m-seg.pt`). Acepta tanto el
|
||||
nombre corto (`face_yolov8m.pt`) como el ya prefijado; si no trae prefijo
|
||||
`bbox/` ni `segm/`, asume `bbox/` (caras/manos son detectores de bounding box).
|
||||
"""
|
||||
if name.startswith(("bbox/", "segm/")):
|
||||
return name
|
||||
return f"bbox/{name}"
|
||||
|
||||
|
||||
def _find_first(workflow: dict, class_type: str) -> str | None:
|
||||
"""Devuelve el node_id del primer nodo con ese class_type, o None."""
|
||||
for node_id, node in workflow.items():
|
||||
if isinstance(node, dict) and node.get("class_type") == class_type:
|
||||
return node_id
|
||||
return None
|
||||
|
||||
|
||||
def comfyui_build_facedetailer_workflow(
|
||||
base_workflow_or_image,
|
||||
ckpt_name: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
bbox_model: str = "face_yolov8m.pt",
|
||||
denoise: float = 0.5,
|
||||
steps: int = 20,
|
||||
cfg: float = 8.0,
|
||||
seed: int = 0,
|
||||
guide_size: float = 512.0,
|
||||
bbox_threshold: float = 0.5,
|
||||
feather: int = 5,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
filename_prefix: str = "facedetail",
|
||||
) -> dict:
|
||||
"""Construye un workflow ComfyUI que aplica FaceDetailer a una imagen.
|
||||
|
||||
Args:
|
||||
base_workflow_or_image: o bien el nombre (str) de una imagen ya presente
|
||||
en el `input/` del servidor, o bien un workflow base (dict en API
|
||||
format, p.ej. el de `comfyui_build_txt2img_workflow`). Con str se monta
|
||||
un `LoadImage` y un `CheckpointLoaderSimple` nuevos; con dict se toma la
|
||||
imagen del primer `VAEDecode` y se reutiliza su `CheckpointLoaderSimple`.
|
||||
ckpt_name: checkpoint para el sampler del detailer (y para el loader nuevo
|
||||
en el modo imagen). Debe existir en el servidor (CheckpointLoaderSimple).
|
||||
positive: prompt positivo para regenerar las caras (p.ej. "detailed face,
|
||||
sharp eyes, skin texture"). Se codifica con el CLIP del checkpoint.
|
||||
negative: prompt negativo. Por defecto "".
|
||||
bbox_model: modelo de deteccion de Ultralytics. Acepta nombre corto
|
||||
("face_yolov8m.pt") o prefijado ("bbox/face_yolov8m.pt"). keyword-only.
|
||||
denoise: fuerza de re-difusion de cada cara (0.5 por defecto; mas alto =
|
||||
mas cambio, mas riesgo de perder identidad). keyword-only.
|
||||
steps: pasos de sampling del detailer. keyword-only.
|
||||
cfg: classifier-free guidance del detailer. keyword-only.
|
||||
seed: semilla del sampler del detailer. keyword-only.
|
||||
guide_size: tamano (px) al que se reescala cada cara recortada antes de
|
||||
re-difundirla (FaceDetailer.guide_size). keyword-only.
|
||||
bbox_threshold: umbral de confianza del detector de caras (0..1). Mas alto
|
||||
= menos falsos positivos, riesgo de no detectar caras pequenas.
|
||||
keyword-only.
|
||||
feather: pixeles de difuminado del borde de la mascara al recomponer la
|
||||
cara sobre la imagen. keyword-only.
|
||||
sampler_name: sampler del detailer (ej. "euler"). keyword-only.
|
||||
scheduler: scheduler del detailer (ej. "normal"). keyword-only.
|
||||
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para `comfyui_submit_workflow`. En el modo dict
|
||||
contiene los nodos del workflow base mas los del detailer (con node_ids
|
||||
prefijados `fd_` para no colisionar); el SaveImage `fd_save` produce la
|
||||
imagen con las caras regeneradas.
|
||||
|
||||
Raises:
|
||||
ValueError: si se pasa un dict sin `VAEDecode` (no hay fuente de imagen)
|
||||
o un tipo que no es str ni dict.
|
||||
"""
|
||||
bbox_norm = _normalize_bbox_model(bbox_model)
|
||||
|
||||
if isinstance(base_workflow_or_image, str):
|
||||
# Modo imagen: cargar una imagen del input/ y montar un checkpoint nuevo.
|
||||
base: dict = {}
|
||||
nodes = {
|
||||
"fd_load": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": base_workflow_or_image},
|
||||
},
|
||||
"fd_ckpt": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
}
|
||||
image_in = ["fd_load", 0]
|
||||
ckpt_id = "fd_ckpt"
|
||||
elif isinstance(base_workflow_or_image, dict):
|
||||
# Modo workflow: tomar la imagen del VAEDecode y reutilizar el checkpoint.
|
||||
base = dict(base_workflow_or_image)
|
||||
vae_decode_id = _find_first(base, "VAEDecode")
|
||||
if vae_decode_id is None:
|
||||
raise ValueError(
|
||||
"comfyui_build_facedetailer_workflow: el workflow base no tiene "
|
||||
"VAEDecode; no hay fuente de imagen para el detailer. Pasa el nombre "
|
||||
"de una imagen (str) o un workflow que decodifique a imagen."
|
||||
)
|
||||
image_in = [vae_decode_id, 0]
|
||||
ckpt_id = _find_first(base, "CheckpointLoaderSimple")
|
||||
nodes = {}
|
||||
if ckpt_id is None:
|
||||
# El base no usa CheckpointLoaderSimple (p.ej. SDXL con otro loader):
|
||||
# montamos uno propio para el sampler del detailer.
|
||||
nodes["fd_ckpt"] = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
}
|
||||
ckpt_id = "fd_ckpt"
|
||||
else:
|
||||
raise ValueError(
|
||||
"comfyui_build_facedetailer_workflow: base_workflow_or_image debe ser "
|
||||
f"str (nombre de imagen) o dict (workflow), no {type(base_workflow_or_image).__name__}."
|
||||
)
|
||||
|
||||
model = [ckpt_id, 0]
|
||||
clip = [ckpt_id, 1]
|
||||
vae = [ckpt_id, 2]
|
||||
|
||||
nodes.update(
|
||||
{
|
||||
"fd_pos": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": clip},
|
||||
},
|
||||
"fd_neg": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": clip},
|
||||
},
|
||||
"fd_det": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {"model_name": bbox_norm},
|
||||
},
|
||||
"fd_face": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": image_in,
|
||||
"model": model,
|
||||
"clip": clip,
|
||||
"vae": vae,
|
||||
"positive": ["fd_pos", 0],
|
||||
"negative": ["fd_neg", 0],
|
||||
"bbox_detector": ["fd_det", 0],
|
||||
"guide_size": guide_size,
|
||||
"guide_size_for": True,
|
||||
"max_size": 1024.0,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": denoise,
|
||||
"feather": feather,
|
||||
"noise_mask": True,
|
||||
"force_inpaint": True,
|
||||
"bbox_threshold": bbox_threshold,
|
||||
"bbox_dilation": 10,
|
||||
"bbox_crop_factor": 3.0,
|
||||
"sam_detection_hint": "center-1",
|
||||
"sam_dilation": 0,
|
||||
"sam_threshold": 0.93,
|
||||
"sam_bbox_expansion": 0,
|
||||
"sam_mask_hint_threshold": 0.7,
|
||||
"sam_mask_hint_use_negative": "False",
|
||||
"drop_size": 10,
|
||||
"wildcard": "",
|
||||
"cycle": 1,
|
||||
},
|
||||
},
|
||||
"fd_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": filename_prefix, "images": ["fd_face", 0]},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {**base, **nodes}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
# Modo imagen: regenerar caras de una imagen ya en el input/ del servidor.
|
||||
wf = comfyui_build_facedetailer_workflow(
|
||||
"portrait_00001_.png",
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="detailed face, sharp eyes, skin texture",
|
||||
negative="blurry, deformed",
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: comfyui_build_grid
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_build_grid(image_paths: list, *, cols: int | None = None, cell: int = 512, out_path: str | None = None, labels: list | None = None) -> dict"
|
||||
description: "Monta un grid / contact-sheet PIL de N imagenes para comparacion visual (p.ej. el output de comfyui_batch_generate con varios seeds). Cada celda conserva el aspect ratio (thumbnail centrado sobre fondo oscuro); rejilla casi cuadrada por defecto (cols=ceil(sqrt(N))). Rotulos opcionales por celda. Usa PIL (Pillow) del venv del registry. Devuelve {ok, out_path, rows, cols, error}. Impura: lee N imagenes y escribe un PNG."
|
||||
tags: [comfyui, ml, grid, montage, pil, image]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_paths
|
||||
desc: "lista de rutas a las imagenes a montar, en orden de lectura (izq->der, arriba->abajo)."
|
||||
- name: cols
|
||||
desc: "numero de columnas; si None usa ceil(sqrt(N)) para una rejilla casi cuadrada."
|
||||
- name: cell
|
||||
desc: "lado en pixeles de cada celda cuadrada; la imagen se reduce para caber conservando proporcion (default 512)."
|
||||
- name: out_path
|
||||
desc: "ruta del PNG de salida; si None escribe 'comfy_grid.png' en el dir de la primera imagen."
|
||||
- name: labels
|
||||
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
||||
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_build_grid.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_grid import comfyui_build_grid
|
||||
|
||||
imgs = [
|
||||
os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"),
|
||||
os.path.expanduser("~/ComfyUI/output/comfy_00002_.png"),
|
||||
os.path.expanduser("~/ComfyUI/output/comfy_00003_.png"),
|
||||
os.path.expanduser("~/ComfyUI/output/comfy_00004_.png"),
|
||||
]
|
||||
res = comfyui_build_grid(imgs, cols=2, cell=512, out_path="/tmp/seeds_grid.png",
|
||||
labels=["seed 1", "seed 2", "seed 3", "seed 4"])
|
||||
# {'ok': True, 'out_path': '/tmp/seeds_grid.png', 'rows': 2, 'cols': 2, 'error': ''}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras un barrido de seeds con `comfyui_batch_generate` + `comfyui_fetch_output_image`:
|
||||
en vez de abrir N PNGs uno a uno, montas un unico contact-sheet para elegir de un
|
||||
vistazo la mejor variante (o comparar steps/cfg/sampler distintos). Tambien sirve
|
||||
para documentar un report con una rejilla de resultados. Es post-proceso local
|
||||
puro de imagen: no toca el servidor ComfyUI.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Si alguna ruta de `image_paths` no existe, devuelve `ok=False` con la lista de
|
||||
faltantes (estricto): no monta una rejilla parcial silenciosamente. Filtra las
|
||||
rutas validas antes si quieres tolerar ausencias.
|
||||
- Cada imagen se reduce a `cell` px conservando proporcion (thumbnail); imagenes de
|
||||
distinto tamano quedan centradas en su celda con relleno, no estiradas.
|
||||
- `labels` se dibuja con la fuente por defecto de PIL (pequeña, sin TTF externo);
|
||||
para rotulos grandes habria que pasar una fuente — no soportado hoy (KISS).
|
||||
- Escribe el PNG en disco: si `out_path` apunta a un directorio inexistente lo crea;
|
||||
si no tiene permiso devuelve `ok=False` con el error.
|
||||
- N grande con `cell` alto produce un canvas enorme (rows*cols*cell^2 px): para
|
||||
decenas de imagenes baja `cell` (p.ej. 256) para no agotar memoria.
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Monta un grid / contact-sheet PIL de N imagenes para comparacion visual.
|
||||
|
||||
Funcion impura: lee N imagenes de disco y escribe un PNG de salida. Usa PIL
|
||||
(Pillow), presente en el venv del registry.
|
||||
|
||||
El compañero natural de comfyui_batch_generate: ese encola N variantes de un
|
||||
workflow (una por seed) pero no junta los resultados. Esta funcion toma las N
|
||||
imagenes ya descargadas (p.ej. con comfyui_fetch_output_image) y las dispone en
|
||||
una rejilla regular para compararlas de un vistazo. Cada celda conserva el aspect
|
||||
ratio (thumbnail centrado sobre fondo oscuro). Opcionalmente rotula cada celda.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
|
||||
def comfyui_build_grid(
|
||||
image_paths: list,
|
||||
*,
|
||||
cols: int | None = None,
|
||||
cell: int = 512,
|
||||
out_path: str | None = None,
|
||||
labels: list | None = None,
|
||||
) -> dict:
|
||||
"""Compone una rejilla de imagenes y la guarda como PNG.
|
||||
|
||||
Args:
|
||||
image_paths: lista de rutas a las imagenes (PNG/JPG/...) a montar, en
|
||||
orden de lectura (izquierda->derecha, arriba->abajo).
|
||||
cols: numero de columnas; si None se usa ceil(sqrt(N)) para una rejilla
|
||||
casi cuadrada. keyword-only.
|
||||
cell: lado en pixeles de cada celda cuadrada; cada imagen se reduce para
|
||||
caber dentro conservando su proporcion. keyword-only.
|
||||
out_path: ruta del PNG de salida; si None se escribe "comfy_grid.png" en
|
||||
el directorio de la primera imagen. keyword-only.
|
||||
labels: rotulos opcionales, uno por imagen (mismo orden); si se pasan, se
|
||||
reserva una franja bajo cada celda y se dibuja el texto. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si el grid se monto y guardo.
|
||||
- out_path (str): ruta del PNG generado.
|
||||
- rows (int): filas de la rejilla.
|
||||
- cols (int): columnas de la rejilla.
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {"ok": False, "out_path": "", "rows": 0, "cols": 0, "error": ""}
|
||||
|
||||
if not image_paths:
|
||||
out["error"] = "image_paths vacio: nada que montar"
|
||||
return out
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
|
||||
return out
|
||||
|
||||
missing = [p for p in image_paths if not os.path.isfile(p)]
|
||||
if missing:
|
||||
out["error"] = f"no existen {len(missing)} rutas: {missing[:5]}"
|
||||
return out
|
||||
|
||||
n = len(image_paths)
|
||||
cols = int(cols) if cols and cols > 0 else max(1, math.ceil(math.sqrt(n)))
|
||||
rows = math.ceil(n / cols)
|
||||
cell = max(16, int(cell))
|
||||
label_h = 22 if labels else 0
|
||||
bg = (24, 24, 28)
|
||||
fg = (232, 232, 236)
|
||||
|
||||
canvas = Image.new("RGB", (cols * cell, rows * (cell + label_h)), bg)
|
||||
draw = ImageDraw.Draw(canvas) if labels else None
|
||||
|
||||
try:
|
||||
for i, path in enumerate(image_paths):
|
||||
with Image.open(path) as src:
|
||||
im = src.convert("RGB")
|
||||
im.thumbnail((cell, cell))
|
||||
r, c = divmod(i, cols)
|
||||
x = c * cell + (cell - im.width) // 2
|
||||
y = r * (cell + label_h) + (cell - im.height) // 2
|
||||
canvas.paste(im, (x, y))
|
||||
if draw is not None and i < len(labels):
|
||||
tx = c * cell + 4
|
||||
ty = r * (cell + label_h) + cell + 3
|
||||
draw.text((tx, ty), str(labels[i]), fill=fg)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo leer/decodificar una imagen: {exc}"
|
||||
return out
|
||||
|
||||
if out_path is None:
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(image_paths[0])),
|
||||
"comfy_grid.png")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
canvas.save(out_path)
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo escribir {out_path!r}: {exc}"
|
||||
return out
|
||||
|
||||
out.update(ok=True, out_path=out_path, rows=rows, cols=cols)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
paths = sys.argv[1:]
|
||||
if not paths:
|
||||
print("uso: comfyui_build_grid.py <img1> <img2> ...", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
res = comfyui_build_grid(paths, out_path="/tmp/comfy_grid.png")
|
||||
print(json.dumps(res, indent=2))
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: comfyui_build_hires_fix_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_hires_fix_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, first_pass: tuple[int, int] = (768, 768), upscale_by: float = 1.5, denoise: float = 0.4, steps: int = 20, cfg: float = 7.0, seed: int = 0, upscale_model: str = \"4x_foolhardy_Remacri.pth\", sampler_name: str = \"euler\", scheduler: str = \"normal\", tile_width: int = 512, tile_height: int = 512, filename_prefix: str = \"hires\") -> dict"
|
||||
description: "Construye un workflow ComfyUI de hires-fix de 2 pasadas en API format: genera una imagen base pequena (KSampler) y la amplia re-difundiendola por tiles con UltimateSDUpscale + un modelo de upscale (Remacri), anadiendo detalle real a alta resolucion. UltimateSDUpscale es la segunda pasada de muestreo (recibe model/positive/negative/vae). Distinto de comfyui_build_upscale_workflow, que es ESRGAN puro sin re-difusion. Class_types verificados en /object_info. Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, hires-fix, ultimatesdupscale, upscale, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ckpt_name
|
||||
desc: "Checkpoint tal como lo ve el servidor (CheckpointLoaderSimple)."
|
||||
- name: positive
|
||||
desc: "Prompt positivo (se usa en la base y en la re-difusion tiled)."
|
||||
- name: negative
|
||||
desc: "Prompt negativo. Por defecto ''."
|
||||
- name: first_pass
|
||||
desc: "(ancho, alto) en px de la pasada base (latente pequeno y rapido). Por defecto (768, 768). keyword-only."
|
||||
- name: upscale_by
|
||||
desc: "Factor de ampliacion de UltimateSDUpscale sobre la imagen base (1.5 -> 768 pasa a 1152). keyword-only."
|
||||
- name: denoise
|
||||
desc: "Fuerza de re-difusion de la segunda pasada (0.4 por defecto). <1 conserva la composicion base y solo anade detalle; 1.0 la re-generaria entera. keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling (ambas pasadas). keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance (ambas pasadas). keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla de la pasada base (UltimateSDUpscale usa la misma). keyword-only."
|
||||
- name: upscale_model
|
||||
desc: "Modelo de upscale en models/upscale_models/ que usa UltimateSDUpscale para escalar antes de re-difundir (ej. '4x_foolhardy_Remacri.pth'). keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Sampler (ambas pasadas). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler (ambas pasadas). keyword-only."
|
||||
- name: tile_width
|
||||
desc: "Ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos = menos VRAM, mas costuras. keyword-only."
|
||||
- name: tile_height
|
||||
desc: "Alto de tile de UltimateSDUpscale (px). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG final que escribe SaveImage. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. node_ids: '4' CheckpointLoaderSimple, '5' EmptyLatentImage, '6'/'7' CLIPTextEncode, '3' KSampler (base), '8' VAEDecode, '11' UpscaleModelLoader, '12' UltimateSDUpscale, '9' SaveImage."
|
||||
tested: true
|
||||
tests: ["cadena base (KSampler) + UltimateSDUpscale + SaveImage", "denoise de la 2a pasada <1 (re-difusion parcial)", "first_pass refleja width/height en EmptyLatentImage", "upscale_model llega a UpscaleModelLoader", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_hires_fix_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_hires_fix_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_hires_fix_workflow import comfyui_build_hires_fix_workflow
|
||||
|
||||
wf = comfyui_build_hires_fix_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="a fox in a forest, intricate detail, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
first_pass=(768, 768),
|
||||
upscale_by=1.5,
|
||||
denoise=0.4,
|
||||
seed=42,
|
||||
)
|
||||
# wf["3"]["class_type"] == "KSampler" # pasada base
|
||||
# wf["12"]["class_type"] == "UltimateSDUpscale" # pasada de detalle (re-difusion)
|
||||
# wf["12"]["inputs"]["denoise"] == 0.4 # <1 = solo anade detalle
|
||||
# wf["11"]["inputs"]["model_name"] == "4x_foolhardy_Remacri.pth"
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_hires_fix_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una imagen a baja resolución se ve plana o sin detalle y quieres una
|
||||
versión grande y nítida que el modelo "redibuja" en alta (no un simple escalado).
|
||||
Es el "hires fix" idiomático: genera la base pequeña y rápida, luego añade detalle
|
||||
real al ampliar. Úsala cuando `comfyui_build_upscale_workflow` (ESRGAN puro) se
|
||||
queda corto porque no inventa detalle nuevo. Después: `comfyui_submit_workflow`
|
||||
→ `comfyui_wait_result` → `comfyui_fetch_output_image`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI.
|
||||
- Requiere el custom node **UltimateSDUpscale** (`comfyui_ultimatesdupscale`). Si
|
||||
el server responde HTTP 400 "node type not found: UltimateSDUpscale", el custom
|
||||
node no está cargado.
|
||||
- El `upscale_model` debe existir en `models/upscale_models/` (aquí
|
||||
`4x_foolhardy_Remacri.pth`). Sin él, el server rechaza el workflow al encolar.
|
||||
- **2 etapas de muestreo, 1 KSampler explícito**: UltimateSDUpscale re-samplea
|
||||
cada tile internamente (por eso recibe `model`/`positive`/`negative`/`vae`), así
|
||||
que el grafo tiene el KSampler base + el UltimateSDUpscale, no dos KSampler.
|
||||
- `denoise` de la 2ª pasada controla cuánto cambia: 0.3–0.45 añade detalle sin
|
||||
alterar la composición; >0.6 puede deformar caras o introducir artefactos.
|
||||
- `upscale_by` alto + `tile_width/height` grandes = más VRAM. En 8 GB conviene
|
||||
tiles de 512 y `upscale_by` 1.5–2.0.
|
||||
- Coste real: la 2ª pasada re-difunde N tiles, es bastante más lenta que un upscale
|
||||
ESRGAN puro. Para solo agrandar sin re-difusión usa `comfyui_build_upscale_workflow`.
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Construye un workflow ComfyUI de "hires fix" de 2 pasadas en API format.
|
||||
|
||||
El hires fix clasico genera una imagen pequena nitida y luego la amplia
|
||||
*re-difundiendola* (no solo escalando pixeles), de modo que el modelo anade
|
||||
detalle coherente a la resolucion alta. Este builder lo implementa con
|
||||
UltimateSDUpscale (custom node), que hace la segunda pasada por TILES con un
|
||||
modelo de upscale (ESRGAN/Remacri) + un sampler con `denoise` parcial:
|
||||
|
||||
Pasada 1 (base): CheckpointLoaderSimple -> CLIPTextEncode(+/-) +
|
||||
EmptyLatentImage(first_pass) -> KSampler -> VAEDecode
|
||||
Pasada 2 (detalle): UpscaleModelLoader(Remacri) +
|
||||
UltimateSDUpscale(image, model, +/-, vae, upscale_model,
|
||||
upscale_by, denoise<1, tiled) -> SaveImage
|
||||
|
||||
UltimateSDUpscale ES la segunda pasada de muestreo: re-samplea cada tile con el
|
||||
checkpoint (de ahi que reciba `model`, `positive`, `negative`, `vae`), por eso el
|
||||
grafo tiene UN KSampler explicito (la base) + el UltimateSDUpscale (el detalle).
|
||||
Distinto de `comfyui_build_upscale_workflow`, que es ESRGAN puro SIN re-difusion.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def comfyui_build_hires_fix_workflow(
|
||||
ckpt_name: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
first_pass: tuple[int, int] = (768, 768),
|
||||
upscale_by: float = 1.5,
|
||||
denoise: float = 0.4,
|
||||
steps: int = 20,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
upscale_model: str = "4x_foolhardy_Remacri.pth",
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
tile_width: int = 512,
|
||||
tile_height: int = 512,
|
||||
filename_prefix: str = "hires",
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow hires-fix (base + UltimateSDUpscale).
|
||||
|
||||
Args:
|
||||
ckpt_name: checkpoint tal como lo ve el servidor (CheckpointLoaderSimple).
|
||||
positive: prompt positivo (se usa en la base y en la re-difusion tiled).
|
||||
negative: prompt negativo. Por defecto "".
|
||||
first_pass: (ancho, alto) en px de la pasada base (latente pequeno y
|
||||
rapido). Por defecto (768, 768). keyword-only.
|
||||
upscale_by: factor de ampliacion de UltimateSDUpscale sobre la imagen base
|
||||
(1.5 -> 768 pasa a 1152). keyword-only.
|
||||
denoise: fuerza de re-difusion de la segunda pasada (0.4 por defecto).
|
||||
<1 para conservar la composicion base y solo anadir detalle; 1.0 la
|
||||
re-generaria entera. keyword-only.
|
||||
steps: pasos de sampling (ambas pasadas). keyword-only.
|
||||
cfg: classifier-free guidance (ambas pasadas). keyword-only.
|
||||
seed: semilla de la pasada base (UltimateSDUpscale usa la misma).
|
||||
keyword-only.
|
||||
upscale_model: modelo de upscale en models/upscale_models/ que usa
|
||||
UltimateSDUpscale para escalar antes de re-difundir (ej.
|
||||
"4x_foolhardy_Remacri.pth"). keyword-only.
|
||||
sampler_name: sampler (ambas pasadas). keyword-only.
|
||||
scheduler: scheduler (ambas pasadas). keyword-only.
|
||||
tile_width: ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos =
|
||||
menos VRAM, mas costuras. keyword-only.
|
||||
tile_height: alto de tile de UltimateSDUpscale (px). keyword-only.
|
||||
filename_prefix: prefijo del PNG final que escribe SaveImage. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para `comfyui_submit_workflow`. node_ids:
|
||||
"4" CheckpointLoaderSimple, "5" EmptyLatentImage, "6"/"7" CLIPTextEncode,
|
||||
"3" KSampler (base), "8" VAEDecode, "11" UpscaleModelLoader,
|
||||
"12" UltimateSDUpscale, "9" SaveImage.
|
||||
"""
|
||||
w, h = first_pass
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": w, "height": h, "batch_size": 1},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": upscale_model},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"inputs": {
|
||||
"image": ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"vae": ["4", 2],
|
||||
"upscale_model": ["11", 0],
|
||||
"upscale_by": upscale_by,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": denoise,
|
||||
"mode_type": "Linear",
|
||||
"tile_width": tile_width,
|
||||
"tile_height": tile_height,
|
||||
"mask_blur": 8,
|
||||
"tile_padding": 32,
|
||||
"seam_fix_mode": "None",
|
||||
"seam_fix_denoise": 1.0,
|
||||
"seam_fix_width": 64,
|
||||
"seam_fix_mask_blur": 8,
|
||||
"seam_fix_padding": 16,
|
||||
"force_uniform_tiles": True,
|
||||
"tiled_decode": False,
|
||||
},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": filename_prefix, "images": ["12", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_hires_fix_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
positive="a fox in a forest, intricate detail, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
first_pass=(768, 768),
|
||||
upscale_by=1.5,
|
||||
denoise=0.4,
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: comfyui_build_image_to_3d_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.1.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_image_to_3d_workflow(image_name: str, ckpt_name: str = \"hunyuan3d-dit-v2-mini.safetensors\", *, resolution: int = 3072, steps: int = 30, cfg: float = 5.5, seed: int = 0, octree_resolution: int = 256, num_chunks: int = 8000, threshold: float = 0.6, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"3d_mesh\", watertight: bool = False) -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI imagen->malla 3D en API format usando los nodos NATIVOS de Hunyuan3D-2 de ComfyUI 0.26.0 (sin custom node). Cadena de 9 nodos: LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode -> Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler -> VAEDecodeHunyuan3D -> (VoxelToMeshBasic | VoxelToMesh surface-net si watertight=True) -> SaveGLB. El SaveGLB produce un .glb. Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: image_name
|
||||
desc: "Nombre del archivo de imagen en el input/ del servidor ComfyUI (ej. '3d_src_robot_00001_.png'). Lo carga el nodo LoadImage; debe existir ya en input/ (subelo antes o usa el pipeline oneshot)."
|
||||
- name: ckpt_name
|
||||
desc: "Nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor (ej. 'hunyuan3d-dit-v2-mini.safetensors'). Debe estar en la lista de comfyui_object_info para ImageOnlyCheckpointLoader."
|
||||
- name: resolution
|
||||
desc: "Resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor = mas detalle de forma y mas VRAM. keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler de difusion 3D. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale del KSampler. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la malla. keyword-only."
|
||||
- name: octree_resolution
|
||||
desc: "Resolucion del grid de voxels en VAEDecodeHunyuan3D. Mayor = malla mas densa (mas caras) y mas memoria. keyword-only."
|
||||
- name: num_chunks
|
||||
desc: "Numero de chunks de decode del VAE 3D; controla el troceado del grid para caber en memoria. keyword-only."
|
||||
- name: threshold
|
||||
desc: "Umbral de iso-superficie del nodo voxel->malla (marching cubes / surface net sobre el grid de voxels). keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler del KSampler (ej. 'euler'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (ej. 'normal'). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del archivo de malla que SaveGLB escribe en output/ (ej. '3d_mesh' -> '3d_mesh_00001_.glb'). keyword-only."
|
||||
- name: watertight
|
||||
desc: "Si False (default, retro-compatible) el nodo '8' es VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con algorithm='surface net', que produce una malla estanca/manifold de raiz sin post-proceso. keyword-only."
|
||||
output: "dict en API format con node_ids '1'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow. El nodo '9' (SaveGLB) produce el archivo .glb en el output del servidor. El nodo '8' es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net (watertight=True)."
|
||||
tested: true
|
||||
tests: ["cadena de 9 nodos Hunyuan3D-2 nativos", "imagen, checkpoint, seed reflejados y SaveGLB presente", "watertight=True usa VoxelToMesh surface-net; default conserva VoxelToMeshBasic", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_image_to_3d_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_image_to_3d_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_image_to_3d_workflow import comfyui_build_image_to_3d_workflow
|
||||
|
||||
wf = comfyui_build_image_to_3d_workflow(
|
||||
image_name="3d_src_robot_00001_.png",
|
||||
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
|
||||
seed=42,
|
||||
)
|
||||
# wf["2"]["class_type"] == "ImageOnlyCheckpointLoader"
|
||||
# wf["3"]["inputs"]["clip_vision"] == ["2", 1] # CLIP_VISION del loader
|
||||
# wf["7"]["class_type"] == "VAEDecodeHunyuan3D"
|
||||
# wf["9"]["class_type"] == "SaveGLB"
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_image_to_3d_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de enviar una reconstruccion imagen->3D a ComfyUI: construye aqui el dict
|
||||
del workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que tengas una
|
||||
imagen ya en el `input/` del servidor y quieras una malla GLB sin escribir el
|
||||
grafo de 9 nodos a mano. Para hacerlo end-to-end desde una imagen en disco (subir
|
||||
+ build + submit + wait + fetch en una llamada), usa el pipeline
|
||||
`comfyui_image_to_3d_oneshot`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
|
||||
links). No se pega en la UI tal cual; es el formato que acepta POST /prompt.
|
||||
- Usa nodos NATIVOS de Hunyuan3D-2 de ComfyUI >= 0.26.0. En versiones anteriores
|
||||
(sin `ImageOnlyCheckpointLoader`/`VAEDecodeHunyuan3D`/`SaveGLB`) el server
|
||||
rechaza el workflow al enviarlo. Esta funcion es pura y no valida contra el
|
||||
server: valida con `comfyui_validate_workflow` antes de encolar si dudas.
|
||||
- `image_name` debe existir en el `input/` del servidor ANTES de enviar. Esta
|
||||
funcion solo referencia el nombre; no sube nada (es pura). El pipeline oneshot
|
||||
hace el upload.
|
||||
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
|
||||
servidor (instalalo con `comfyui_install_3d_model`).
|
||||
- El camino nativo es **shape-only**: la malla sale SIN color/textura. Para color
|
||||
por vertice o textura horneada haria falta el wrapper de kijai (compila
|
||||
custom_rasterizer) — fuera de alcance.
|
||||
- Con el default (`watertight=False`) el nodo `VoxelToMeshBasic` produce malla NO
|
||||
estanca ("cube-soup"), lo esperable; se arregla a posteriori con
|
||||
`comfyui_make_watertight` o el pipeline `comfyui_mesh_cleanup_oneshot`. Para
|
||||
malla estanca DE RAÍZ pasa `watertight=True`: usa `VoxelToMesh` con
|
||||
`algorithm="surface net"` (manifold cerrado sin reparar, ver report 0088). El
|
||||
nodo `VoxelToMesh` es nativo de ComfyUI >= 0.26.0 (`nodes_hunyuan3d.py`); en
|
||||
versiones sin él, usar el default + post-proceso.
|
||||
- `octree_resolution` alto (256) produce mallas muy densas (decenas de MB de GLB,
|
||||
>1M caras) sin decimacion. Para web conviene un paso de simplificacion posterior
|
||||
(`comfyui_simplify_mesh` / `comfyui_mesh_cleanup_oneshot`).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-24) — añade `watertight=False` (keyword-only, retro-compatible):
|
||||
con `True` el nodo voxel→malla usa `VoxelToMesh` (`algorithm="surface net"`) en
|
||||
vez de `VoxelToMeshBasic`, para mallas estancas de raíz sin post-proceso. El
|
||||
default conserva el comportamiento histórico exacto.
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Construye un workflow ComfyUI imagen -> malla 3D en "API format" (Hunyuan3D-2 nativo).
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
|
||||
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
|
||||
|
||||
El workflow usa los nodos NATIVOS de Hunyuan3D-2 que trae ComfyUI 0.26.0 (sin
|
||||
custom node de terceros): una imagen de entrada se reconstruye en una malla 3D
|
||||
GLB. Cadena de 9 nodos:
|
||||
|
||||
LoadImage -> ImageOnlyCheckpointLoader -> CLIPVisionEncode ->
|
||||
Hunyuan3Dv2Conditioning -> EmptyLatentHunyuan3Dv2 -> KSampler ->
|
||||
VAEDecodeHunyuan3D -> (VoxelToMeshBasic | VoxelToMesh) -> SaveGLB
|
||||
|
||||
El paso voxel->malla depende del parametro `watertight`:
|
||||
- watertight=False (default): VoxelToMeshBasic, el comportamiento historico
|
||||
(marching cubes simple; malla NO estanca, "cube-soup", que luego se arregla con
|
||||
comfyui_make_watertight).
|
||||
- watertight=True: VoxelToMesh con algorithm="surface net" (verificado en
|
||||
/object_info, nodes_hunyuan3d.py), que produce una malla manifold/estanca de
|
||||
raiz, sin post-proceso (ver report 0088).
|
||||
|
||||
El checkpoint Hunyuan3D-2 (mini/standard) es self-contained: ImageOnlyCheckpointLoader
|
||||
devuelve MODEL, CLIP_VISION y VAE de un solo .safetensors.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_image_to_3d_workflow(
|
||||
image_name: str,
|
||||
ckpt_name: str = "hunyuan3d-dit-v2-mini.safetensors",
|
||||
*,
|
||||
resolution: int = 3072,
|
||||
steps: int = 30,
|
||||
cfg: float = 5.5,
|
||||
seed: int = 0,
|
||||
octree_resolution: int = 256,
|
||||
num_chunks: int = 8000,
|
||||
threshold: float = 0.6,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
filename_prefix: str = "3d_mesh",
|
||||
watertight: bool = False,
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow imagen->3D nativo (Hunyuan3D-2).
|
||||
|
||||
Args:
|
||||
image_name: nombre del archivo de imagen en el `input/` del servidor
|
||||
ComfyUI (ej. "3d_src_robot_00001_.png"). Lo carga el nodo LoadImage;
|
||||
debe existir ya en input/ (subelo antes, o usa el pipeline oneshot).
|
||||
ckpt_name: nombre del checkpoint Hunyuan3D-2 tal como lo ve el servidor
|
||||
(ej. "hunyuan3d-dit-v2-mini.safetensors"). Debe estar entre los que
|
||||
devuelve comfyui_object_info para ImageOnlyCheckpointLoader.
|
||||
resolution: resolucion del latente 3D (EmptyLatentHunyuan3Dv2). Mayor =
|
||||
mas detalle de forma y mas VRAM. keyword-only.
|
||||
steps: pasos de sampling del KSampler de difusion 3D. keyword-only.
|
||||
cfg: classifier-free guidance scale del KSampler. keyword-only.
|
||||
seed: semilla del KSampler (0 = determinista; cambia para variar la
|
||||
malla). keyword-only.
|
||||
octree_resolution: resolucion del grid de voxels en VAEDecodeHunyuan3D.
|
||||
Mayor = malla mas densa (mas caras) y mas memoria. keyword-only.
|
||||
num_chunks: numero de chunks de decode del VAE 3D; controla el troceado
|
||||
del grid para caber en memoria. keyword-only.
|
||||
threshold: umbral de iso-superficie del nodo voxel->malla (marching cubes
|
||||
/ surface net sobre el grid de voxels). keyword-only.
|
||||
sampler_name: nombre del sampler del KSampler (ej. "euler"). keyword-only.
|
||||
scheduler: scheduler del sampler (ej. "normal"). keyword-only.
|
||||
filename_prefix: prefijo del archivo de malla que SaveGLB escribe en
|
||||
output/ (ej. "3d_mesh" -> "3d_mesh_00001_.glb"). keyword-only.
|
||||
watertight: si False (default, retro-compatible) el nodo "8" es
|
||||
VoxelToMeshBasic (malla NO estanca). Si True usa VoxelToMesh con
|
||||
algorithm="surface net", que produce una malla estanca/manifold de
|
||||
raiz sin post-proceso. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format con node_ids "1".."9" como claves; cada valor tiene
|
||||
class_type + inputs. Listo para comfyui_submit_workflow. El nodo "9"
|
||||
(SaveGLB) produce el archivo .glb en el output del servidor. El nodo "8"
|
||||
es VoxelToMeshBasic (watertight=False) o VoxelToMesh surface-net
|
||||
(watertight=True).
|
||||
"""
|
||||
voxel_node = (
|
||||
{
|
||||
"class_type": "VoxelToMesh",
|
||||
"inputs": {
|
||||
"voxel": ["7", 0],
|
||||
"algorithm": "surface net",
|
||||
"threshold": threshold,
|
||||
},
|
||||
}
|
||||
if watertight
|
||||
else {
|
||||
"class_type": "VoxelToMeshBasic",
|
||||
"inputs": {"voxel": ["7", 0], "threshold": threshold},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": image_name},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "ImageOnlyCheckpointLoader",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPVisionEncode",
|
||||
"inputs": {
|
||||
"clip_vision": ["2", 1],
|
||||
"image": ["1", 0],
|
||||
"crop": "center",
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "Hunyuan3Dv2Conditioning",
|
||||
"inputs": {"clip_vision_output": ["3", 0]},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentHunyuan3Dv2",
|
||||
"inputs": {"resolution": resolution, "batch_size": 1},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": ["2", 0],
|
||||
"positive": ["4", 0],
|
||||
"negative": ["4", 1],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "VAEDecodeHunyuan3D",
|
||||
"inputs": {
|
||||
"samples": ["6", 0],
|
||||
"vae": ["2", 2],
|
||||
"num_chunks": num_chunks,
|
||||
"octree_resolution": octree_resolution,
|
||||
},
|
||||
},
|
||||
"8": voxel_node,
|
||||
"9": {
|
||||
"class_type": "SaveGLB",
|
||||
"inputs": {"mesh": ["8", 0], "filename_prefix": filename_prefix},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_image_to_3d_workflow(
|
||||
image_name="3d_src_robot_00001_.png",
|
||||
ckpt_name="hunyuan3d-dit-v2-mini.safetensors",
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: comfyui_build_img2img_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_img2img_workflow(ckpt_name: str, init_image: str, positive: str, negative: str = \"\", *, denoise: float = 0.6, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI img2img en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage -> VAEEncode -> KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, ml, image-generation, img2img, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ckpt_name
|
||||
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
|
||||
- name: init_image
|
||||
desc: "Nombre del archivo de imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
|
||||
- name: positive
|
||||
desc: "Prompt positivo: lo que se quiere ver en la imagen."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
|
||||
- name: denoise
|
||||
desc: "Fuerza de denoising del KSampler (0.0 = identica a la base, 1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
|
||||
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["usa VAEEncode/LoadImage y no EmptyLatentImage", "denoise e init_image reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_img2img_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_img2img_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_img2img_workflow import comfyui_build_img2img_workflow
|
||||
|
||||
wf = comfyui_build_img2img_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
init_image="cabin.png", # archivo en el input/ de ComfyUI
|
||||
positive="a cozy cabin in the woods, golden hour",
|
||||
negative="blurry, low quality",
|
||||
denoise=0.55, # conserva ~la mitad de la imagen base
|
||||
seed=42,
|
||||
)
|
||||
# wf["11"]["class_type"] == "VAEEncode"
|
||||
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente de la imagen
|
||||
# wf["3"]["inputs"]["denoise"] == 0.55
|
||||
```
|
||||
|
||||
El bloque de arriba se lanza con el python del venv (`python/.venv/bin/python3`). Nota: `./fn run` directo no aplica a este builder porque su firma usa `*` (keyword-only) y el generador de runner de `fn run` no lo soporta — igual que en `comfyui_build_txt2img_workflow`. Usa el import de arriba o un heredoc.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras transformar una imagen existente con un prompt (variaciones,
|
||||
restyling, refine) en lugar de generar desde ruido. Sube primero la imagen base
|
||||
al `input/` del servidor (o cargala por la UI) y pasa su nombre en `init_image`.
|
||||
Para generar desde cero usa `comfyui_build_txt2img_workflow`; para ampliar una
|
||||
imagen usa `comfyui_build_upscale_workflow`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
|
||||
acepta POST /prompt.
|
||||
- `init_image` debe existir en la carpeta `input/` del servidor (no es un path
|
||||
local arbitrario). Subela antes con la UI o copiala a `~/ComfyUI/input/`.
|
||||
- `denoise` controla cuanto se conserva de la base: cerca de 1.0 ignora la
|
||||
imagen (casi txt2img); cerca de 0.0 apenas la cambia. 0.4-0.7 es el rango util.
|
||||
- Asume que el checkpoint trae VAE embebido (VAEEncode/VAEDecode usan `["4", 2]`).
|
||||
Para un VAE externo cambia esas conexiones.
|
||||
- Es pura: NO valida que `ckpt_name`/`init_image` existan en el servidor. Si no
|
||||
existen, ComfyUI rechaza el workflow con HTTP 400 al enviarlo. Valida antes con
|
||||
`comfyui_validate_workflow`.
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Construye un workflow ComfyUI img2img en API format (dict de nodos numerados).
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
|
||||
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_img2img_workflow(
|
||||
ckpt_name: str,
|
||||
init_image: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
denoise: float = 0.6,
|
||||
steps: int = 20,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow img2img para SD1.5 / SDXL.
|
||||
|
||||
Cadena de nodos: CheckpointLoaderSimple + LoadImage -> VAEEncode ->
|
||||
KSampler (con denoise < 1.0 para conservar la imagen base) -> VAEDecode ->
|
||||
SaveImage. CLIPTextEncode codifica el prompt positivo y el negativo.
|
||||
|
||||
Args:
|
||||
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
|
||||
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
|
||||
comfyui_object_info para CheckpointLoaderSimple.
|
||||
init_image: nombre del archivo de imagen base dentro de la carpeta
|
||||
input/ del servidor ComfyUI (lo que carga el nodo LoadImage).
|
||||
positive: prompt positivo (lo que se quiere ver en la imagen).
|
||||
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
|
||||
denoise: fuerza de denoising del KSampler (0.0 = identica a la base,
|
||||
1.0 = ignora la base). Tipico 0.4-0.7 para img2img. keyword-only.
|
||||
steps: pasos de sampling del KSampler. keyword-only.
|
||||
cfg: classifier-free guidance scale. keyword-only.
|
||||
seed: semilla del KSampler. keyword-only.
|
||||
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
|
||||
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids y cada valor tiene class_type + inputs.
|
||||
"""
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": init_image},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["10", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": denoise,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["11", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "comfy_img2img", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_img2img_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
init_image="example.png",
|
||||
positive="a cozy cabin in the woods, golden hour, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
denoise=0.6,
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: comfyui_build_inpaint_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_inpaint_workflow(ckpt_name: str, image: str, mask: str, positive: str, negative: str = \"\", *, denoise: float = 1.0, steps: int = 20, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI inpaint en API format para SD1.5/SDXL: CheckpointLoaderSimple + LoadImage (base) + LoadImageMask (mascara) -> VAEEncodeForInpaint -> KSampler -> VAEDecode -> SaveImage. Regenera solo la zona enmascarada conservando el resto. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, ml, image-generation, inpaint, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ckpt_name
|
||||
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'dreamshaper_8.safetensors'). Debe estar en la lista de CheckpointLoaderSimple de comfyui_object_info."
|
||||
- name: image
|
||||
desc: "Nombre del archivo de la imagen base dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
|
||||
- name: mask
|
||||
desc: "Nombre del archivo de la mascara dentro de input/ del servidor; lo carga LoadImageMask. Las zonas blancas se regeneran."
|
||||
- name: positive
|
||||
desc: "Prompt positivo: lo que se quiere ver en la zona enmascarada."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
|
||||
- name: denoise
|
||||
desc: "Fuerza de denoising del KSampler (1.0 regenera por completo la zona enmascarada; <1.0 conserva parte de la base). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
|
||||
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["usa LoadImageMask+VAEEncodeForInpaint", "imagen base, mascara, seed y denoise reflejados", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_inpaint_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_inpaint_workflow import comfyui_build_inpaint_workflow
|
||||
|
||||
wf = comfyui_build_inpaint_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
image="room.png", # imagen base en el input/ de ComfyUI
|
||||
mask="room_mask.png", # mascara: blanco = zona a regenerar
|
||||
positive="a vase of red flowers on the table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
denoise=1.0,
|
||||
seed=42,
|
||||
)
|
||||
# wf["11"]["class_type"] == "VAEEncodeForInpaint"
|
||||
# wf["11"]["inputs"]["mask"] == ["12", 0] # mascara desde LoadImageMask
|
||||
# wf["3"]["inputs"]["latent_image"] == ["11", 0] # KSampler parte del latente inpaint
|
||||
```
|
||||
|
||||
El bloque se lanza con el python del venv (`python/.venv/bin/python3`). `./fn run`
|
||||
directo no aplica a este builder porque su firma usa `*` (keyword-only); usa el
|
||||
import de arriba o un heredoc.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras reemplazar solo una parte de una imagen (quitar un objeto, cambiar
|
||||
un detalle, rellenar una zona) conservando el resto intacto. Sube la imagen base
|
||||
y la mascara al `input/` del servidor y pasa sus nombres. Para transformar la
|
||||
imagen entera usa `comfyui_build_img2img_workflow`; para generar desde cero
|
||||
`comfyui_build_txt2img_workflow`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
|
||||
acepta POST /prompt.
|
||||
- `image` y `mask` deben existir en la carpeta `input/` del servidor (no son
|
||||
paths locales arbitrarios). Subelos antes con la UI o copialos a `~/ComfyUI/input/`.
|
||||
- `LoadImageMask` lee el canal `red` por defecto: la mascara debe tener la zona a
|
||||
regenerar en blanco. Si tu mascara usa el canal alpha, cambia `channel` en el
|
||||
nodo '12' tras construir.
|
||||
- `VAEEncodeForInpaint` usa `grow_mask_by: 6` (suaviza el borde de la mascara).
|
||||
Ajustalo en el nodo '11' si necesitas un borde mas duro o mas difuso.
|
||||
- Asume que el checkpoint trae VAE embebido (VAEEncodeForInpaint/VAEDecode usan
|
||||
`["4", 2]`). Para un VAE externo cambia esas conexiones.
|
||||
- Es pura: NO valida que `ckpt_name`/`image`/`mask` existan en el servidor.
|
||||
Valida antes con `comfyui_validate_workflow`.
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Construye un workflow ComfyUI inpaint en API format (dict de nodos numerados).
|
||||
|
||||
Inpaint: se reemplaza la zona enmascarada de una imagen conservando el resto.
|
||||
Cadena de nodos: CheckpointLoaderSimple + LoadImage (imagen base) +
|
||||
LoadImageMask (mascara) -> VAEEncodeForInpaint (codifica el latente respetando
|
||||
la mascara) -> KSampler -> VAEDecode -> SaveImage. Los CLIPTextEncode codifican
|
||||
el prompt positivo y el negativo.
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
|
||||
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_inpaint_workflow(
|
||||
ckpt_name: str,
|
||||
image: str,
|
||||
mask: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
denoise: float = 1.0,
|
||||
steps: int = 20,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow inpaint para SD1.5 / SDXL.
|
||||
|
||||
Args:
|
||||
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
|
||||
(ej. "dreamshaper_8.safetensors"). Debe estar entre los que devuelve
|
||||
comfyui_object_info para CheckpointLoaderSimple.
|
||||
image: nombre del archivo de la imagen base dentro de la carpeta input/
|
||||
del servidor ComfyUI (lo carga el nodo LoadImage).
|
||||
mask: nombre del archivo de la mascara dentro de input/ del servidor
|
||||
(lo carga LoadImageMask; las zonas blancas se regeneran).
|
||||
positive: prompt positivo (lo que se quiere ver en la zona enmascarada).
|
||||
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
|
||||
denoise: fuerza de denoising del KSampler (1.0 regenera por completo la
|
||||
zona enmascarada; <1.0 conserva parte de la base). keyword-only.
|
||||
steps: pasos de sampling del KSampler. keyword-only.
|
||||
cfg: classifier-free guidance scale. keyword-only.
|
||||
seed: semilla del KSampler. 0 es determinista; cambiar para variar.
|
||||
keyword-only.
|
||||
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m"). keyword-only.
|
||||
scheduler: scheduler del sampler (ej. "normal", "karras"). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids y cada valor tiene class_type + inputs.
|
||||
"""
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": image},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "LoadImageMask",
|
||||
"inputs": {"image": mask, "channel": "red"},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "VAEEncodeForInpaint",
|
||||
"inputs": {
|
||||
"pixels": ["10", 0],
|
||||
"vae": ["4", 2],
|
||||
"mask": ["12", 0],
|
||||
"grow_mask_by": 6,
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": denoise,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["11", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "comfy_inpaint", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_inpaint_workflow(
|
||||
ckpt_name="dreamshaper_8.safetensors",
|
||||
image="room.png",
|
||||
mask="room_mask.png",
|
||||
positive="a vase of red flowers on the table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: comfyui_build_sdxl_refiner_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_sdxl_refiner_workflow(base_ckpt: str, refiner_ckpt: str, positive: str, negative: str = \"\", *, base_steps: int = 20, refiner_steps: int = 5, cfg: float = 7.0, seed: int = 0, width: int = 1024, height: int = 1024) -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI SDXL base+refiner en API format: dos KSamplerAdvanced encadenados que comparten el total de pasos. El base arranca el ruido y devuelve el latente con ruido sobrante (return_with_leftover_noise=enable), el refiner lo recoge (add_noise=disable) y lo termina. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, ml, image-generation, sdxl, refiner, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: base_ckpt
|
||||
desc: "Nombre del checkpoint base SDXL tal como lo ve el servidor (ej. 'sd_xl_base_1.0.safetensors'). En CheckpointLoaderSimple."
|
||||
- name: refiner_ckpt
|
||||
desc: "Nombre del checkpoint refiner SDXL (ej. 'sd_xl_refiner_1.0.safetensors')."
|
||||
- name: positive
|
||||
desc: "Prompt positivo: lo que se quiere ver. Se usa para el CLIP del base y el del refiner."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
|
||||
- name: base_steps
|
||||
desc: "Pasos que ejecuta el sampler base (del 0 a base_steps). keyword-only."
|
||||
- name: refiner_steps
|
||||
desc: "Pasos que ejecuta el refiner (de base_steps al total). El total es base_steps + refiner_steps. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale (compartido por ambos samplers). keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla de ruido (compartida por ambos samplers). keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del latente/imagen en px (SDXL nativo 1024). keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del latente/imagen en px (SDXL nativo 1024). keyword-only."
|
||||
output: "dict en API format con node_ids como claves (CheckpointLoaderSimple base '4' y refiner '14', EmptyLatentImage '5', CLIPTextEncode base '6'/'7' y refiner '16'/'17', KSamplerAdvanced base '3' y refiner '15', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["dos KSamplerAdvanced encadenados", "base emite ruido sobrante y refiner lo recoge (start/end_at_step compartidos)", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_sdxl_refiner_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_sdxl_refiner_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
|
||||
|
||||
wf = comfyui_build_sdxl_refiner_workflow(
|
||||
base_ckpt="sd_xl_base_1.0.safetensors",
|
||||
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
|
||||
positive="a majestic lion on a cliff at sunset, ultra detailed",
|
||||
negative="blurry, low quality",
|
||||
base_steps=20, refiner_steps=5,
|
||||
seed=42,
|
||||
)
|
||||
# wf["3"]["inputs"]["steps"] == 25 # total = base + refiner
|
||||
# wf["3"]["inputs"]["end_at_step"] == 20 # base corta en base_steps
|
||||
# wf["15"]["inputs"]["start_at_step"] == 20 # refiner arranca ahi
|
||||
# wf["15"]["inputs"]["latent_image"] == ["3", 0] # encadenado del base
|
||||
```
|
||||
|
||||
El bloque se lanza con el python del venv. `./fn run` directo no aplica (firma con
|
||||
`*` keyword-only); usa el import o un heredoc.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando uses el pipeline oficial SDXL de dos etapas (checkpoint base + checkpoint
|
||||
refiner) para pulir el detalle final. Si solo tienes un checkpoint SDXL completo
|
||||
(sin refiner separado) usa `comfyui_build_txt2img_workflow` con width/height 1024
|
||||
— el refiner separado solo merece la pena con `sd_xl_refiner_*`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI.
|
||||
- Los dos KSamplerAdvanced comparten `steps` = base_steps + refiner_steps. El
|
||||
base va de 0 a base_steps con `return_with_leftover_noise=enable` (no decodifica);
|
||||
el refiner va de base_steps a 10000 (= "hasta el final") con `add_noise=disable`.
|
||||
- El VAE de salida es el del refiner (`["14", 2]`). Ambos checkpoints SDXL traen
|
||||
el mismo VAE, asi que el resultado no cambia; para un VAE externo cambia esa
|
||||
conexion en el nodo '8'.
|
||||
- SDXL es nativo a 1024x1024: bajar mucho la resolucion degrada el resultado.
|
||||
- Es pura: NO valida que los checkpoints existan en el servidor. Valida antes con
|
||||
`comfyui_validate_workflow` (necesitas ambos: base y refiner descargados).
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Construye un workflow ComfyUI SDXL base+refiner en API format.
|
||||
|
||||
SDXL genera en dos etapas: un checkpoint base produce el latente con la mayor
|
||||
parte de los pasos y un checkpoint refiner termina los ultimos pasos para pulir
|
||||
el detalle. Se encadenan dos KSamplerAdvanced compartiendo el numero total de
|
||||
pasos: el base arranca el ruido y devuelve el latente con ruido sobrante
|
||||
(return_with_leftover_noise=enable, no decodifica), y el refiner lo recoge
|
||||
(add_noise=disable) y lo lleva al final.
|
||||
|
||||
Cadena de nodos: CheckpointLoaderSimple base + CheckpointLoaderSimple refiner +
|
||||
EmptyLatentImage + 4 CLIPTextEncode (positivo/negativo por cada CLIP) ->
|
||||
KSamplerAdvanced base -> KSamplerAdvanced refiner -> VAEDecode -> SaveImage.
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Es el
|
||||
formato que acepta POST /prompt, distinto del formato de la UI (graph con links).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_sdxl_refiner_workflow(
|
||||
base_ckpt: str,
|
||||
refiner_ckpt: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
base_steps: int = 20,
|
||||
refiner_steps: int = 5,
|
||||
cfg: float = 7.0,
|
||||
seed: int = 0,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow SDXL base+refiner (dos KSamplerAdvanced).
|
||||
|
||||
Args:
|
||||
base_ckpt: nombre del checkpoint base SDXL tal como lo ve el servidor
|
||||
ComfyUI (ej. "sd_xl_base_1.0.safetensors"). En CheckpointLoaderSimple.
|
||||
refiner_ckpt: nombre del checkpoint refiner SDXL
|
||||
(ej. "sd_xl_refiner_1.0.safetensors").
|
||||
positive: prompt positivo (lo que se quiere ver en la imagen). Se usa
|
||||
tanto para el CLIP del base como para el del refiner.
|
||||
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
|
||||
base_steps: pasos que ejecuta el sampler base (del 0 a base_steps).
|
||||
keyword-only.
|
||||
refiner_steps: pasos que ejecuta el refiner (de base_steps al total).
|
||||
El total de pasos es base_steps + refiner_steps. keyword-only.
|
||||
cfg: classifier-free guidance scale (compartido). keyword-only.
|
||||
seed: semilla de ruido (compartida por ambos samplers). keyword-only.
|
||||
width: ancho del latente/imagen en px (SDXL nativo 1024). keyword-only.
|
||||
height: alto del latente/imagen en px (SDXL nativo 1024). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids y cada valor tiene class_type + inputs.
|
||||
"""
|
||||
total_steps = base_steps + refiner_steps
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": base_ckpt},
|
||||
},
|
||||
"14": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": refiner_ckpt},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"16": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["14", 1]},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["14", 1]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": {
|
||||
"add_noise": "enable",
|
||||
"noise_seed": seed,
|
||||
"steps": total_steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": base_steps,
|
||||
"return_with_leftover_noise": "enable",
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"15": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": {
|
||||
"add_noise": "disable",
|
||||
"noise_seed": seed,
|
||||
"steps": total_steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"start_at_step": base_steps,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable",
|
||||
"model": ["14", 0],
|
||||
"positive": ["16", 0],
|
||||
"negative": ["17", 0],
|
||||
"latent_image": ["3", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["15", 0], "vae": ["14", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "comfy_sdxl", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_sdxl_refiner_workflow(
|
||||
base_ckpt="sd_xl_base_1.0.safetensors",
|
||||
refiner_ckpt="sd_xl_refiner_1.0.safetensors",
|
||||
positive="a majestic lion on a cliff at sunset, ultra detailed",
|
||||
negative="blurry, low quality",
|
||||
base_steps=20,
|
||||
refiner_steps=5,
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: comfyui_build_textured_3d_multiview_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_textured_3d_multiview_workflow(image_name: str, *, ckpt: str = \"hunyuan3d-dit-v2-mv.safetensors\", views: int = 6, octree: int = 384, max_faces: int = 50000, upscale_model: str = \"4x_foolhardy_Remacri.pth\") -> dict"
|
||||
description: "Construye el dict (API format) del pipeline imagen->malla 3D texturizada PBR multi-vista de ComfyUI via el wrapper Hunyuan3DWrapper (kijai). Cadena: LoadImage -> Hy3DModelLoader -> Hy3DGenerateMesh -> Hy3DVAEDecode(octree) -> Hy3DPostprocessMesh(max_faces) -> Hy3DMeshUVWrap -> Hy3DCameraConfig(4 o 6 vistas) + Hy3DRenderMultiView + Hy3DDelightImage -> Hy3DSampleMultiView -> [UpscaleModelLoader+ImageUpscaleWithModel(Remacri)+ImageResize+] -> Hy3DBakeFromMultiview -> Hy3DMeshVerticeInpaintTexture -> Hy3DApplyTexture -> Hy3DExportMesh(glb). Portado del report 0082 (cobertura de atlas 32.93% con 6 vistas + Remacri + octree 384). Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, img-to-3d, texture, multiview, hunyuan3d, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: image_name
|
||||
desc: "Nombre del archivo de imagen de referencia tal como lo ve el servidor ComfyUI en su carpeta input/ (subido con POST /upload/image)."
|
||||
- name: ckpt
|
||||
desc: "Checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader. Por defecto el variante multi-vista hunyuan3d-dit-v2-mv. keyword-only."
|
||||
- name: views
|
||||
desc: "Numero de vistas de camara: 4 (front/left/back/right) o 6 (anade top/bottom, rellena concavidades). Otro valor lanza ValueError. keyword-only."
|
||||
- name: octree
|
||||
desc: "octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina, mas VRAM; 384 en el report 0082). keyword-only."
|
||||
- name: max_faces
|
||||
desc: "max_facenum del Hy3DPostprocessMesh (decimacion; 50000 en el report 0082). keyword-only."
|
||||
- name: upscale_model
|
||||
desc: "Modelo de upscale ESRGAN en upscale_models/ para mejorar las vistas antes del bake (factor dominante de cobertura). Cadena vacia desactiva el upscale. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. node_ids '1'..'19'; los nodos de upscale ('13'..'15') solo presentes si upscale_model esta activo. El SaveGLB-equivalente Hy3DExportMesh produce un .glb texturizado en output/3D/."
|
||||
tested: true
|
||||
tests: ["estructura completa shape+paint+upscale (18 class_types)", "params imagen/ckpt/octree/max_faces reflejados", "6 vistas configuran 6 azimuths/elevations", "4 vistas configuran 4 azimuths", "sin upscale omite nodos Remacri y el bake toma del sample", "views invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_textured_3d_multiview_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_textured_3d_multiview_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_textured_3d_multiview_workflow import (
|
||||
comfyui_build_textured_3d_multiview_workflow,
|
||||
)
|
||||
|
||||
wf = comfyui_build_textured_3d_multiview_workflow(
|
||||
"tex_src_character.png", views=6, octree=384, max_faces=50000,
|
||||
upscale_model="4x_foolhardy_Remacri.pth",
|
||||
)
|
||||
# wf["9"]["class_type"] == "Hy3DCameraConfig" (6 vistas)
|
||||
# wf["19"]["class_type"] == "Hy3DExportMesh" (.glb texturizado)
|
||||
# OJO: en 8GB ejecutar en 2 fases (ver Gotchas), no de una pasada
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_textured_3d_multiview_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras una malla 3D **con textura** desde una sola imagen, con mejor
|
||||
cobertura de atlas que el image-to-3D nativo (que da geometria sin pintar). Es el
|
||||
builder del pipeline de texturizado multi-vista del report 0082: 6 vistas de
|
||||
camara + delight + sample multi-vista + upscale Remacri de las vistas + bake sobre
|
||||
el UV. Para geometria sin textura usa `comfyui_build_image_to_3d_workflow`
|
||||
(nodos nativos, mas ligero).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Ejecutar en 2 fases en 8GB**: el grafo es monolitico (shape + paint en un
|
||||
dict) por claridad, pero el grafo entero da OOM en 8GB (confirmado reports
|
||||
0075/0081/0082). El camino valido es: ejecutar la fase shape (nodos 1-5 ->
|
||||
Hy3DExportMesh del shape), liberar VRAM con `POST /free`, y luego la fase paint
|
||||
arrancando desde `Hy3DLoadMesh` del .glb del shape. La separacion + el /free los
|
||||
orquesta el pipeline impuro que consuma este builder; este dict es la referencia
|
||||
de cableado completo.
|
||||
- Requiere el custom node **ComfyUI-Hunyuan3DWrapper** (kijai) + `custom_rasterizer`
|
||||
CUDA compilado, **ComfyUI_essentials** (para `ImageResize+`) y el modelo
|
||||
`4x_foolhardy_Remacri.pth` en `upscale_models/`. Si falta algo, ComfyUI rechaza
|
||||
el workflow con HTTP 400 (esta funcion es pura y no valida contra el servidor).
|
||||
- `ckpt` por defecto es el variante multi-vista (`-mv`). El report 0082 uso
|
||||
`hy3dgen/hunyuan3d-dit-v2-0-fp16.safetensors`; ajusta `ckpt` al nombre real que
|
||||
el servidor enumere en Hy3DModelLoader.
|
||||
- `upscale_model=""` desactiva el upscale: el bake toma las vistas directas del
|
||||
Hy3DSampleMultiView. Pierde la mejora dominante de cobertura (el report midio
|
||||
20.81% -> 32.93% al cablear Remacri en serie).
|
||||
- Render bonito del GLB no disponible headless; verificar con `Load3D`/`Preview3D`
|
||||
en la UI de ComfyUI o el visor de `apps/img_to_3d_webapp`.
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Construye un workflow ComfyUI imagen->malla 3D texturizada multi-vista (API format).
|
||||
|
||||
Usa el wrapper ComfyUI-Hunyuan3DWrapper (kijai): genera la geometria con
|
||||
Hy3DGenerateMesh/Hy3DVAEDecode, la limpia y le hace UV unwrap, renderiza N vistas
|
||||
de camara, sintetiza la textura multi-vista (Hy3DSampleMultiView) opcionalmente
|
||||
mejorada con un upscaler ESRGAN (Remacri), la hornea sobre el atlas UV
|
||||
(Hy3DBakeFromMultiview), rellena los huecos por vertices y exporta el GLB con
|
||||
material PBR. Portado del pipeline validado en el report 0082 (cobertura de atlas
|
||||
32.93 % con 6 vistas + Remacri + octree 384).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
|
||||
IMPORTANTE: el grafo es monolitico (shape + paint en un solo dict) por claridad,
|
||||
pero en 8 GB de VRAM debe ejecutarse en 2 fases (shape -> /free -> paint), no de
|
||||
una pasada. La separacion en fases y el /free los orquesta el pipeline impuro que
|
||||
consuma este builder. Ver la seccion Gotchas del .md.
|
||||
"""
|
||||
|
||||
# Vistas de camara soportadas: tabla (azimuths, elevations, weights) por numero de vistas.
|
||||
# 4 = front/left/back/right; 6 anade top/bottom (rellena concavidades que 4 camaras no ven).
|
||||
_CAMERA_PRESETS = {
|
||||
4: {
|
||||
"camera_azimuths": "0, 90, 180, 270",
|
||||
"camera_elevations": "0, 0, 0, 0",
|
||||
"view_weights": "1, 0.1, 0.5, 0.1",
|
||||
},
|
||||
6: {
|
||||
"camera_azimuths": "0, 90, 180, 270, 0, 180",
|
||||
"camera_elevations": "0, 0, 0, 0, 90, -90",
|
||||
"view_weights": "1, 0.1, 0.5, 0.1, 0.05, 0.05",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def comfyui_build_textured_3d_multiview_workflow(
|
||||
image_name: str,
|
||||
*,
|
||||
ckpt: str = "hunyuan3d-dit-v2-mv.safetensors",
|
||||
views: int = 6,
|
||||
octree: int = 384,
|
||||
max_faces: int = 50000,
|
||||
upscale_model: str = "4x_foolhardy_Remacri.pth",
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow imagen->3D texturizado multi-vista.
|
||||
|
||||
Args:
|
||||
image_name: nombre del archivo de imagen de referencia tal como lo ve el
|
||||
servidor ComfyUI en su carpeta input/ (subido con POST /upload/image).
|
||||
ckpt: checkpoint del modelo de forma Hunyuan3D para Hy3DModelLoader (por
|
||||
defecto el variante multi-vista hunyuan3d-dit-v2-mv). keyword-only.
|
||||
views: numero de vistas de camara: 4 (front/left/back/right) o 6 (anade
|
||||
top/bottom). Cualquier otro valor lanza ValueError. keyword-only.
|
||||
octree: octree_resolution del Hy3DVAEDecode (mas alto = malla mas fina,
|
||||
mas VRAM). keyword-only.
|
||||
max_faces: max_facenum del Hy3DPostprocessMesh (decimacion de la malla).
|
||||
keyword-only.
|
||||
upscale_model: nombre del modelo de upscale ESRGAN en upscale_models/ para
|
||||
mejorar las vistas antes del bake. Cadena vacia o None desactiva el
|
||||
upscale (el bake toma las vistas directas del sample). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. node_ids "1".."19"
|
||||
(los de upscale "13".."15" solo presentes si upscale_model esta activo).
|
||||
|
||||
Raises:
|
||||
ValueError: si views no es 4 ni 6.
|
||||
"""
|
||||
if views not in _CAMERA_PRESETS:
|
||||
raise ValueError(
|
||||
f"comfyui_build_textured_3d_multiview_workflow: views debe ser 4 o 6, "
|
||||
f"no {views!r}"
|
||||
)
|
||||
cam = _CAMERA_PRESETS[views]
|
||||
|
||||
wf = {
|
||||
# --- Fase shape: imagen -> malla limpia con UV ---
|
||||
"1": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": image_name},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "Hy3DModelLoader",
|
||||
"inputs": {"model": ckpt, "attention_mode": "sdpa", "cublas_ops": False},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "Hy3DGenerateMesh",
|
||||
"inputs": {
|
||||
"pipeline": ["2", 0],
|
||||
"image": ["1", 0],
|
||||
"guidance_scale": 5.5,
|
||||
"steps": 30,
|
||||
"seed": 42,
|
||||
"force_offload": True,
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "Hy3DVAEDecode",
|
||||
"inputs": {
|
||||
"vae": ["2", 1],
|
||||
"latents": ["3", 0],
|
||||
"box_v": 1.01,
|
||||
"octree_resolution": octree,
|
||||
"num_chunks": 8000,
|
||||
"mc_level": 0,
|
||||
"mc_algo": "mc",
|
||||
"enable_flash_vdm": True,
|
||||
"force_offload": True,
|
||||
},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "Hy3DPostprocessMesh",
|
||||
"inputs": {
|
||||
"trimesh": ["4", 0],
|
||||
"remove_floaters": True,
|
||||
"remove_degenerate_faces": True,
|
||||
"reduce_faces": True,
|
||||
"max_facenum": max_faces,
|
||||
"smooth_normals": False,
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "Hy3DMeshUVWrap",
|
||||
"inputs": {"trimesh": ["5", 0]},
|
||||
},
|
||||
# --- Fase paint: render multi-vista + delight + sample + bake + textura ---
|
||||
"7": {
|
||||
"class_type": "DownloadAndLoadHy3DPaintModel",
|
||||
"inputs": {"model": "hunyuan3d-paint-v2-0"},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "DownloadAndLoadHy3DDelightModel",
|
||||
"inputs": {"model": "hunyuan3d-delight-v2-0"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "Hy3DCameraConfig",
|
||||
"inputs": {
|
||||
"camera_azimuths": cam["camera_azimuths"],
|
||||
"camera_elevations": cam["camera_elevations"],
|
||||
"view_weights": cam["view_weights"],
|
||||
"camera_distance": 1.45,
|
||||
"ortho_scale": 1.2,
|
||||
},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "Hy3DRenderMultiView",
|
||||
"inputs": {
|
||||
"trimesh": ["6", 0],
|
||||
"render_size": 1024,
|
||||
"texture_size": 1024,
|
||||
"camera_config": ["9", 0],
|
||||
"normal_space": "world",
|
||||
},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "Hy3DDelightImage",
|
||||
"inputs": {
|
||||
"delight_pipe": ["8", 0],
|
||||
"image": ["1", 0],
|
||||
"steps": 50,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"cfg_image": 1.0,
|
||||
"seed": 42,
|
||||
},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "Hy3DSampleMultiView",
|
||||
"inputs": {
|
||||
"pipeline": ["7", 0],
|
||||
"ref_image": ["11", 0],
|
||||
"normal_maps": ["10", 0],
|
||||
"position_maps": ["10", 1],
|
||||
"view_size": 512,
|
||||
"steps": 25,
|
||||
"seed": 0,
|
||||
"camera_config": ["9", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Upscale opcional de los multiviews antes del bake (factor dominante de cobertura).
|
||||
if upscale_model:
|
||||
wf["13"] = {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": upscale_model},
|
||||
}
|
||||
wf["14"] = {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
|
||||
}
|
||||
wf["15"] = {
|
||||
"class_type": "ImageResize+",
|
||||
"inputs": {
|
||||
"image": ["14", 0],
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"interpolation": "lanczos",
|
||||
"method": "stretch",
|
||||
"condition": "always",
|
||||
"multiple_of": 0,
|
||||
},
|
||||
}
|
||||
bake_images = ["15", 0]
|
||||
else:
|
||||
bake_images = ["12", 0]
|
||||
|
||||
wf["16"] = {
|
||||
"class_type": "Hy3DBakeFromMultiview",
|
||||
"inputs": {
|
||||
"images": bake_images,
|
||||
"renderer": ["10", 2],
|
||||
"camera_config": ["9", 0],
|
||||
},
|
||||
}
|
||||
wf["17"] = {
|
||||
"class_type": "Hy3DMeshVerticeInpaintTexture",
|
||||
"inputs": {"texture": ["16", 0], "mask": ["16", 1], "renderer": ["16", 2]},
|
||||
}
|
||||
wf["18"] = {
|
||||
"class_type": "Hy3DApplyTexture",
|
||||
"inputs": {"texture": ["17", 0], "renderer": ["17", 2]},
|
||||
}
|
||||
wf["19"] = {
|
||||
"class_type": "Hy3DExportMesh",
|
||||
"inputs": {
|
||||
"trimesh": ["18", 0],
|
||||
"filename_prefix": "3D/textured_multiview",
|
||||
"file_format": "glb",
|
||||
"save_file": True,
|
||||
},
|
||||
}
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_textured_3d_multiview_workflow(
|
||||
"tex_src_character.png", views=6, octree=384, max_faces=50000
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: comfyui_build_txt2img_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_txt2img_workflow(ckpt_name: str, positive: str, negative: str = \"\", *, steps: int = 20, cfg: float = 7.0, width: int = 512, height: int = 512, seed: int = 0, sampler_name: str = \"euler\", scheduler: str = \"normal\", filename_prefix: str = \"comfy\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI txt2img en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]) para SD1.5/SDXL: CheckpointLoaderSimple -> CLIPTextEncode x2 + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, image-generation, txt2img, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ckpt_name
|
||||
desc: "Nombre del checkpoint tal como lo ve el servidor ComfyUI (ej. 'v1-5-pruned-emaonly-fp16.safetensors'). Debe estar en la lista que devuelve comfyui_object_info para CheckpointLoaderSimple."
|
||||
- name: positive
|
||||
desc: "Prompt positivo: lo que se quiere ver en la imagen."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling del KSampler. keyword-only."
|
||||
- name: cfg
|
||||
desc: "Classifier-free guidance scale. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del latente/imagen en px, multiplo de 8. keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del latente/imagen en px, multiplo de 8. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
|
||||
- name: sampler_name
|
||||
desc: "Nombre del sampler (ej. 'euler', 'dpmpp_2m'). keyword-only."
|
||||
- name: scheduler
|
||||
desc: "Scheduler del sampler (ej. 'normal', 'karras'). keyword-only."
|
||||
- name: filename_prefix
|
||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||
output: "dict en API format con node_ids '3'..'9' como claves; cada valor tiene class_type + inputs. Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["class_types esperados (6 nodos)", "params seed/steps/cfg/width/height reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_txt2img_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_txt2img_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
steps=20,
|
||||
seed=42,
|
||||
)
|
||||
# wf["3"]["class_type"] == "KSampler"
|
||||
# wf["3"]["inputs"]["model"] == ["4", 0] # conexion al CheckpointLoader
|
||||
# wf["9"]["class_type"] == "SaveImage"
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_txt2img_workflow` (imprime el JSON del workflow de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de enviar una generacion txt2img a ComfyUI: construye aqui el dict del
|
||||
workflow y pasalo a `comfyui_submit_workflow`. Usala siempre que necesites un
|
||||
txt2img basico sin tener que escribir el grafo de nodos a mano. Para workflows
|
||||
mas complejos (img2img, ControlNet, upscalers) construye el dict tu mismo o
|
||||
extiende esta funcion con un builder hermano.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
|
||||
links). No se puede pegar en la UI tal cual; es el formato que acepta POST
|
||||
/prompt.
|
||||
- `ckpt_name` debe coincidir EXACTAMENTE con un checkpoint visible para el
|
||||
servidor. Si no existe, ComfyUI rechaza el workflow con HTTP 400 al enviarlo
|
||||
(no aqui — esta funcion es pura y no valida contra el servidor).
|
||||
- `width`/`height` deben ser multiplos de 8 o el KSampler fallara en el
|
||||
servidor.
|
||||
- Asume que el checkpoint trae VAE embebido (VAEDecode usa `["4", 2]`, la salida
|
||||
VAE del CheckpointLoaderSimple). Para un VAE externo cambia esa conexion.
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Construye un workflow ComfyUI txt2img en "API format" (dict de nodos numerados).
|
||||
|
||||
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
|
||||
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
|
||||
links explicitos).
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_txt2img_workflow(
|
||||
ckpt_name: str,
|
||||
positive: str,
|
||||
negative: str = "",
|
||||
*,
|
||||
steps: int = 20,
|
||||
cfg: float = 7.0,
|
||||
width: int = 512,
|
||||
height: int = 512,
|
||||
seed: int = 0,
|
||||
sampler_name: str = "euler",
|
||||
scheduler: str = "normal",
|
||||
filename_prefix: str = "comfy",
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow txt2img basico para SD1.5 / SDXL.
|
||||
|
||||
Cadena de nodos: CheckpointLoaderSimple -> CLIPTextEncode (positivo y
|
||||
negativo) + EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage.
|
||||
|
||||
Args:
|
||||
ckpt_name: nombre del checkpoint tal como lo ve el servidor ComfyUI
|
||||
(ej. "v1-5-pruned-emaonly-fp16.safetensors"). Debe estar entre los
|
||||
que devuelve comfyui_object_info en CheckpointLoaderSimple.
|
||||
positive: prompt positivo (lo que se quiere ver en la imagen).
|
||||
negative: prompt negativo (lo que se quiere evitar). Por defecto "".
|
||||
steps: pasos de sampling del KSampler.
|
||||
cfg: classifier-free guidance scale.
|
||||
width: ancho del latente/imagen en px (multiplo de 8).
|
||||
height: alto del latente/imagen en px (multiplo de 8).
|
||||
seed: semilla del KSampler (0 = determinista; cambia para variar).
|
||||
sampler_name: nombre del sampler (ej. "euler", "dpmpp_2m").
|
||||
scheduler: scheduler del sampler (ej. "normal", "karras").
|
||||
filename_prefix: prefijo del PNG generado por SaveImage en output/.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids ("3".."9") y cada valor tiene class_type + inputs.
|
||||
"""
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_txt2img_workflow(
|
||||
ckpt_name="v1-5-pruned-emaonly-fp16.safetensors",
|
||||
positive="a red apple on a wooden table, sharp focus",
|
||||
negative="blurry, low quality",
|
||||
steps=20,
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: comfyui_build_upscale_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_upscale_workflow(image: str, *, model_name: str = \"4x-UltraSharp.pth\", method: str = \"model\") -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI de upscale en API format. method='model' usa UpscaleModelLoader + ImageUpscaleWithModel (ESRGAN, alta calidad); method='latent' usa ImageScaleBy (reescalado de pixel x2 sin modelo). Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, upscale, esrgan, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: image
|
||||
desc: "Nombre del archivo de imagen dentro de la carpeta input/ del servidor ComfyUI; lo carga el nodo LoadImage."
|
||||
- name: model_name
|
||||
desc: "Nombre del modelo de upscale en models/upscale_models/ (ej. '4x-UltraSharp.pth'). Solo se usa con method='model'. keyword-only."
|
||||
- name: method
|
||||
desc: "'model' (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel) o 'latent' (reescalado de pixel x2 con ImageScaleBy, sin modelo). keyword-only."
|
||||
output: "dict en API format. Con method='model': LoadImage '10' + UpscaleModelLoader '12' + ImageUpscaleWithModel '13' + SaveImage '9'. Con method='latent': LoadImage '10' + ImageScaleBy '13' + SaveImage '9'. Listo para comfyui_submit_workflow."
|
||||
tested: true
|
||||
tests: ["method='model' usa UpscaleModelLoader+ImageUpscaleWithModel", "method='latent' usa ImageScaleBy sin modelo", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_upscale_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_upscale_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_upscale_workflow import comfyui_build_upscale_workflow
|
||||
|
||||
# Upscale con modelo ESRGAN (necesita el .pth en models/upscale_models/)
|
||||
wf = comfyui_build_upscale_workflow("render.png", model_name="4x-UltraSharp.pth")
|
||||
# wf["12"]["class_type"] == "UpscaleModelLoader"
|
||||
# wf["13"]["inputs"]["upscale_model"] == ["12", 0]
|
||||
|
||||
# Upscale rapido sin modelo (reescalado de pixel x2)
|
||||
wf_latent = comfyui_build_upscale_workflow("render.png", method="latent")
|
||||
# wf_latent["13"]["class_type"] == "ImageScaleBy"
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras ampliar una imagen ya generada. Usa `method="model"` (ESRGAN) para
|
||||
mejor calidad si tienes un upscaler en `models/upscale_models/` (ej. 4x-UltraSharp);
|
||||
usa `method="latent"` para un reescalado rapido sin descargar nada. Pega la salida
|
||||
de un txt2img/img2img como `image` en el input/ del servidor.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `method="latent"` NO es un upscale en espacio latente real (eso requiere un
|
||||
checkpoint+VAE para encode/decode, que esta firma no recibe). Usa `ImageScaleBy`
|
||||
= reescalado de pixel con lanczos x2. Es honesto: barato y sin modelo, pero no
|
||||
recupera detalle como un ESRGAN. Para latent-upscale real construye un workflow
|
||||
con checkpoint + VAEEncode + LatentUpscale + VAEDecode.
|
||||
- Con `method="model"`, `model_name` debe existir en `models/upscale_models/`. Si
|
||||
no, ComfyUI rechaza el workflow al enviarlo (HTTP 400). Valida antes con
|
||||
`comfyui_validate_workflow`.
|
||||
- `image` debe existir en la carpeta `input/` del servidor.
|
||||
- Es pura: no valida contra el servidor.
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Construye un workflow ComfyUI de upscale en API format (dict de nodos numerados).
|
||||
|
||||
Dos modos:
|
||||
- method="model": upscale con modelo ESRGAN (UpscaleModelLoader +
|
||||
ImageUpscaleWithModel). Calidad alta; necesita un modelo en
|
||||
models/upscale_models/ (ej. "4x-UltraSharp.pth").
|
||||
- method="latent": reescalado en espacio de pixel con ImageScaleBy (x2, sin
|
||||
modelo ni checkpoint). Upscale rapido y barato.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_upscale_workflow(
|
||||
image: str,
|
||||
*,
|
||||
model_name: str = "4x-UltraSharp.pth",
|
||||
method: str = "model",
|
||||
) -> dict:
|
||||
"""Construye el dict de un workflow de upscale para una imagen cargada.
|
||||
|
||||
Args:
|
||||
image: nombre del archivo de imagen dentro de la carpeta input/ del
|
||||
servidor ComfyUI (lo que carga el nodo LoadImage).
|
||||
model_name: nombre del modelo de upscale en models/upscale_models/
|
||||
(ej. "4x-UltraSharp.pth"). Solo se usa con method="model".
|
||||
keyword-only.
|
||||
method: "model" (ESRGAN via UpscaleModelLoader + ImageUpscaleWithModel)
|
||||
o "latent" (reescalado de pixel x2 con ImageScaleBy, sin modelo).
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow.
|
||||
|
||||
Raises:
|
||||
ValueError: si method no es "model" ni "latent".
|
||||
"""
|
||||
if method not in ("model", "latent"):
|
||||
raise ValueError(
|
||||
f"comfyui_build_upscale_workflow: method invalido {method!r}; "
|
||||
"usa 'model' o 'latent'."
|
||||
)
|
||||
load = {
|
||||
"10": {"class_type": "LoadImage", "inputs": {"image": image}},
|
||||
}
|
||||
if method == "model":
|
||||
return {
|
||||
**load,
|
||||
"12": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": model_name},
|
||||
},
|
||||
"13": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["12", 0], "image": ["10", 0]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "comfy_upscale",
|
||||
"images": ["13", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
# method == "latent": reescalado de pixel x2 sin modelo
|
||||
return {
|
||||
**load,
|
||||
"13": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": 2.0,
|
||||
"image": ["10", 0],
|
||||
},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "comfy_upscale", "images": ["13", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
print(json.dumps(comfyui_build_upscale_workflow("example.png"), indent=2))
|
||||
print(json.dumps(comfyui_build_upscale_workflow("example.png", method="latent"), indent=2))
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: comfyui_build_video_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_video_workflow(prompt: str, *, model: str = \"ltx\", negative: str = \"\", width: int = 512, height: int = 320, num_frames: int = 65, steps: int = 20, seed: int = 0, fps: int = 24) -> dict"
|
||||
description: "Construye el dict de un workflow ComfyUI txt2video en API format para LTX-Video 2B v0.9.5 (model='ltx') o Wan2.1 T2V 1.3B (model='wan'), con los nombres de modelo reales. LTX: CLIPLoader(ltxv)+CheckpointLoaderSimple -> CLIPTextEncode x2 -> LTXVConditioning+EmptyLTXVLatentVideo+LTXVScheduler+KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo. Wan: UNETLoader+CLIPLoader(wan)+VAELoader+ModelSamplingSD3 -> CLIPTextEncode x2+EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) -> VAEDecode -> CreateVideo -> SaveVideo. Defaults conservadores para 8GB. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
|
||||
tags: [comfyui, ml, video-generation, txt2video, ltx-video, wan, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: prompt
|
||||
desc: "Prompt positivo: lo que se quiere ver en el clip de video."
|
||||
- name: model
|
||||
desc: "'ltx' (LTX-Video 2B v0.9.5, todo-en-uno) o 'wan' (Wan2.1 T2V 1.3B, diffusion+vae aparte). Cualquier otro valor lanza ValueError. keyword-only."
|
||||
- name: negative
|
||||
desc: "Prompt negativo: lo que se quiere evitar. Por defecto cadena vacia. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del video en px (multiplo de 32 recomendado). keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del video en px (multiplo de 32 recomendado). keyword-only."
|
||||
- name: num_frames
|
||||
desc: "Numero de frames del clip (longitud temporal del latente de video). keyword-only."
|
||||
- name: steps
|
||||
desc: "Pasos de sampling: LTXVScheduler para ltx, KSampler para wan. keyword-only."
|
||||
- name: seed
|
||||
desc: "Semilla del sampler. 0 es determinista; cambiar para variar el clip. keyword-only."
|
||||
- name: fps
|
||||
desc: "Frames por segundo del video (CreateVideo). En LTX se usa tambien como frame_rate del LTXVConditioning. keyword-only."
|
||||
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. LTX devuelve 12 nodos; Wan 11. La cfg/sampler/scheduler se fijan internamente segun el modelo (LTX: cfg 3.0, euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0)."
|
||||
tested: true
|
||||
tests: ["LTX: nodos LTXV* presentes + t5xxl fp8 + ckpt real", "Wan: UNETLoader/VAELoader/ModelSamplingSD3 + umt5 + wan_2.1_vae", "params reflejados (width/height/num_frames/steps/seed/fps)", "model invalido lanza ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_video_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_video_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_video_workflow import comfyui_build_video_workflow
|
||||
|
||||
wf = comfyui_build_video_workflow(
|
||||
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
|
||||
model="ltx",
|
||||
negative="low quality, worst quality, deformed, motion smear",
|
||||
width=512, height=320, num_frames=65, steps=25, seed=42, fps=24,
|
||||
)
|
||||
# wf["72"]["class_type"] == "SamplerCustom" (camino LTX)
|
||||
# wf["79"]["class_type"] == "SaveVideo"
|
||||
# -> comfyui_submit_workflow(wf) para encolar el clip
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_video_workflow` (imprime el JSON del workflow LTX de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de enviar una generacion de video txt2video a ComfyUI: construye aqui el
|
||||
dict del workflow y pasalo a `comfyui_submit_workflow`. Usa `model="ltx"` por
|
||||
defecto (cupo en 8GB confirmado, scheduler y VAE temporales propios); `model="wan"`
|
||||
si quieres el camino Wan2.1 1.3B (umt5 + vae aparte). Hermana de
|
||||
`comfyui_build_txt2img_workflow` para imagen estatica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
|
||||
acepta POST /prompt.
|
||||
- Los nombres de modelo estan fijados a los reales del equipo
|
||||
(`ltx-video-2b-v0.9.5.safetensors` + `t5xxl_fp8_e4m3fn_scaled.safetensors`;
|
||||
`wan2.1_t2v_1.3B_fp16.safetensors` + `umt5_xxl_fp8_e4m3fn_scaled.safetensors` +
|
||||
`wan_2.1_vae.safetensors`). Deben existir y ser visibles para el servidor o
|
||||
ComfyUI rechaza el workflow con HTTP 400 al enviarlo (esta funcion es pura y no
|
||||
valida contra el servidor).
|
||||
- Cupo 8GB: con los defaults (512x320, 65 frames) LTX pico ~7.7 GB en el report
|
||||
0084 sin OOM. Subir resolucion o num_frames acerca el techo. Si da OOM, bajar a
|
||||
512x288 / 49 frames.
|
||||
- El camino LTX esta validado de extremo a extremo (report 0084: clip real de 65
|
||||
frames). El camino Wan modela la plantilla nativa canonica de ComfyUI pero NO se
|
||||
ejecuto en esa sesion; verificar nombres de modelo antes de tirar de el.
|
||||
- LTX usa cfg baja (3.0). Subirla degrada el video. Por eso la cfg no es parametro:
|
||||
se fija segun el modelo.
|
||||
- `SaveVideo` necesita `format`/`codec` (aqui "auto"/"auto"); sin ellos ComfyUI
|
||||
responde HTTP 400 (gotcha del importador, report 0084). Este builder ya los pone.
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Construye un workflow ComfyUI txt2video en "API format" (dict de nodos numerados).
|
||||
|
||||
Soporta dos modelos de difusion de video nativos de ComfyUI 0.26, ambos pensados
|
||||
para caber en 8 GB de VRAM con parametros conservadores:
|
||||
|
||||
- model="ltx": LTX-Video 2B v0.9.5. Checkpoint todo-en-uno (UNet + VAE temporal) +
|
||||
text encoder t5xxl en fp8. Cadena CLIPLoader(ltxv) + CheckpointLoaderSimple ->
|
||||
CLIPTextEncode x2 -> LTXVConditioning + EmptyLTXVLatentVideo + LTXVScheduler +
|
||||
KSamplerSelect -> SamplerCustom -> VAEDecode -> CreateVideo -> SaveVideo.
|
||||
Validado de extremo a extremo en el report 0084 (clip real de 65 frames).
|
||||
|
||||
- model="wan": Wan2.1 T2V 1.3B. Diffusion model (UNETLoader) + text encoder umt5
|
||||
fp8 (CLIPLoader type=wan) + wan_2.1_vae aparte (VAELoader) + ModelSamplingSD3 ->
|
||||
CLIPTextEncode x2 + EmptyHunyuanLatentVideo -> KSampler(uni_pc/simple) ->
|
||||
VAEDecode -> CreateVideo -> SaveVideo. Plantilla nativa canonica de ComfyUI.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
"""
|
||||
|
||||
# Nombres reales de los modelos tal como los ve el servidor ComfyUI.
|
||||
_LTX_CKPT = "ltx-video-2b-v0.9.5.safetensors"
|
||||
_LTX_CLIP = "t5xxl_fp8_e4m3fn_scaled.safetensors"
|
||||
_WAN_UNET = "wan2.1_t2v_1.3B_fp16.safetensors"
|
||||
_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
|
||||
_WAN_VAE = "wan_2.1_vae.safetensors"
|
||||
|
||||
|
||||
def comfyui_build_video_workflow(
|
||||
prompt: str,
|
||||
*,
|
||||
model: str = "ltx",
|
||||
negative: str = "",
|
||||
width: int = 512,
|
||||
height: int = 320,
|
||||
num_frames: int = 65,
|
||||
steps: int = 20,
|
||||
seed: int = 0,
|
||||
fps: int = 24,
|
||||
) -> dict:
|
||||
"""Construye el dict del workflow txt2video para LTX-Video 2B o Wan2.1 1.3B.
|
||||
|
||||
Args:
|
||||
prompt: prompt positivo (lo que se quiere ver en el clip).
|
||||
model: "ltx" (LTX-Video 2B v0.9.5) o "wan" (Wan2.1 T2V 1.3B). keyword-only.
|
||||
negative: prompt negativo. keyword-only.
|
||||
width: ancho del video en px (multiplo de 32 recomendado). keyword-only.
|
||||
height: alto del video en px (multiplo de 32 recomendado). keyword-only.
|
||||
num_frames: numero de frames del clip (longitud temporal del latente).
|
||||
keyword-only.
|
||||
steps: pasos de sampling (LTXVScheduler para ltx, KSampler para wan).
|
||||
keyword-only.
|
||||
seed: semilla del sampler (0 = determinista; cambiar para variar).
|
||||
keyword-only.
|
||||
fps: frames por segundo del video resultante (CreateVideo). En LTX se usa
|
||||
ademas como frame_rate del condicionamiento LTXVConditioning.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||
node_ids (string) y cada valor tiene class_type + inputs. La cfg, el
|
||||
sampler y el scheduler se fijan internamente segun el modelo (LTX: cfg 3.0,
|
||||
euler; Wan: cfg 6.0, uni_pc/simple, shift 8.0).
|
||||
|
||||
Raises:
|
||||
ValueError: si model no es "ltx" ni "wan".
|
||||
"""
|
||||
m = model.lower()
|
||||
if m == "ltx":
|
||||
return {
|
||||
"38": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {"clip_name": _LTX_CLIP, "type": "ltxv", "device": "default"},
|
||||
},
|
||||
"44": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": _LTX_CKPT},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt, "clip": ["38", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["38", 0]},
|
||||
},
|
||||
"70": {
|
||||
"class_type": "EmptyLTXVLatentVideo",
|
||||
"inputs": {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"length": num_frames,
|
||||
"batch_size": 1,
|
||||
},
|
||||
},
|
||||
"71": {
|
||||
"class_type": "LTXVScheduler",
|
||||
"inputs": {
|
||||
"steps": steps,
|
||||
"max_shift": 2.05,
|
||||
"base_shift": 0.95,
|
||||
"stretch": True,
|
||||
"terminal": 0.1,
|
||||
"latent": ["70", 0],
|
||||
},
|
||||
},
|
||||
"73": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {"sampler_name": "euler"},
|
||||
},
|
||||
"69": {
|
||||
"class_type": "LTXVConditioning",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"frame_rate": fps,
|
||||
},
|
||||
},
|
||||
"72": {
|
||||
"class_type": "SamplerCustom",
|
||||
"inputs": {
|
||||
"model": ["44", 0],
|
||||
"positive": ["69", 0],
|
||||
"negative": ["69", 1],
|
||||
"sampler": ["73", 0],
|
||||
"sigmas": ["71", 0],
|
||||
"latent_image": ["70", 0],
|
||||
"add_noise": True,
|
||||
"noise_seed": seed,
|
||||
"cfg": 3.0,
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["72", 0], "vae": ["44", 2]},
|
||||
},
|
||||
"78": {
|
||||
"class_type": "CreateVideo",
|
||||
"inputs": {"images": ["8", 0], "fps": fps},
|
||||
},
|
||||
"79": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {
|
||||
"video": ["78", 0],
|
||||
"filename_prefix": "video",
|
||||
"format": "auto",
|
||||
"codec": "auto",
|
||||
},
|
||||
},
|
||||
}
|
||||
if m == "wan":
|
||||
return {
|
||||
"37": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"},
|
||||
},
|
||||
"38": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {"clip_name": _WAN_CLIP, "type": "wan", "device": "default"},
|
||||
},
|
||||
"39": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": _WAN_VAE},
|
||||
},
|
||||
"48": {
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"inputs": {"shift": 8.0, "model": ["37", 0]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt, "clip": ["38", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["38", 0]},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "EmptyHunyuanLatentVideo",
|
||||
"inputs": {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"length": num_frames,
|
||||
"batch_size": 1,
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": 6.0,
|
||||
"sampler_name": "uni_pc",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1.0,
|
||||
"model": ["48", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["40", 0],
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["3", 0], "vae": ["39", 0]},
|
||||
},
|
||||
"78": {
|
||||
"class_type": "CreateVideo",
|
||||
"inputs": {"images": ["8", 0], "fps": fps},
|
||||
},
|
||||
"79": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {
|
||||
"video": ["78", 0],
|
||||
"filename_prefix": "video",
|
||||
"format": "auto",
|
||||
"codec": "auto",
|
||||
},
|
||||
},
|
||||
}
|
||||
raise ValueError(
|
||||
f"comfyui_build_video_workflow: model debe ser 'ltx' o 'wan', no {model!r}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_video_workflow(
|
||||
"A red fox runs through a sunlit autumn forest, cinematic, shallow depth of field",
|
||||
model="ltx",
|
||||
negative="low quality, worst quality, deformed, motion smear",
|
||||
seed=42,
|
||||
)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: comfyui_build_view_3d_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_build_view_3d_workflow(model_file: str, *, animation: bool = False, width: int = 1024, height: int = 1024) -> dict"
|
||||
description: "Construye el dict API-format de un visor 3D minimo de ComfyUI con el nodo nativo Load3D (display 'Load 3D & Animation', comfy_extras.nodes_load_3d, categoria 3d) para VISUALIZAR un GLB/GLTF/OBJ/FBX/STL/PLY existente, orbitando con el raton, sin ejecutar el grafo (no es output node). animation=True usa Load3DAdvanced (input viewport_state, control avanzado de camara); animation=False usa Load3D (input image de estado del visor, el del report 0079). Pura, sin red ni I/O."
|
||||
tags: [comfyui, ml, view-3d, load3d, mesh, workflow, viewer]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: model_file
|
||||
desc: "Ruta del modelo RELATIVA al input/ del servidor ComfyUI (ej. '3d/fox_mv_textured.glb'). El archivo debe existir ya bajo ~/ComfyUI/input/3d/ para que el visor lo cargue (Load3D solo lista ese directorio)."
|
||||
- name: animation
|
||||
desc: "Si True usa Load3DAdvanced (viewport_state, control avanzado de camara/viewport para inspeccionar modelos animados); si False (default) usa Load3D, el visor estandar. Ambos reproducen animaciones embebidas del modelo en el frontend. keyword-only."
|
||||
- name: width
|
||||
desc: "Ancho del viewport del nodo en px. keyword-only."
|
||||
- name: height
|
||||
desc: "Alto del viewport del nodo en px. keyword-only."
|
||||
output: "dict en API format con un unico nodo '1'. Con animation=False: class_type 'Load3D', inputs {model_file, image, width, height}. Con animation=True: class_type 'Load3DAdvanced', inputs {model_file, viewport_state, width, height}. Cargable con comfyui_load_workflow_ui (inyecta en la UI del navegador) o POSTeable a /prompt."
|
||||
tested: true
|
||||
tests: ["Load3D simple con model_file/width/height", "animation=True usa Load3DAdvanced", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_build_view_3d_workflow.py"
|
||||
file_path: "python/functions/ml/comfyui_build_view_3d_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_view_3d_workflow import comfyui_build_view_3d_workflow
|
||||
|
||||
wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb")
|
||||
# wf == {"1": {"class_type": "Load3D",
|
||||
# "inputs": {"model_file": "3d/fox_mv_textured.glb", "image": "",
|
||||
# "width": 1024, "height": 1024}}}
|
||||
|
||||
# Inyectar en la UI abierta (visor interactivo, orbita con el raton):
|
||||
# from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui
|
||||
# comfyui_load_workflow_ui(wf, server_url_substr="8188")
|
||||
|
||||
# Variante avanzada (control de camara/viewport):
|
||||
wf_adv = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True)
|
||||
# wf_adv["1"]["class_type"] == "Load3DAdvanced"
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_build_view_3d_workflow` (imprime los dos workflows de ejemplo).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tengas un mesh GLB/OBJ (p.ej. la salida de `comfyui_image_to_3d_oneshot`,
|
||||
descargada con `comfyui_fetch_output_mesh`) y quieras VERLO con su textura/color dentro
|
||||
de un nodo de ComfyUI, interactivo. Construye aquí el dict del visor y cárgalo en la UI
|
||||
con `comfyui_load_workflow_ui`. Es shape+textura: el visor Three.js pinta el material PBR
|
||||
del GLB (report 0079: el zorro se ve naranja, no gris). Para añadir el nodo SIN reemplazar
|
||||
el grafo abierto del usuario, el método no-destructivo es inyectarlo vía CDP
|
||||
(`LiteGraph.createNode('Load3D')` + `app.graph.add`), ver report 0079.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`model_file` debe ser ruta RELATIVA a `input/`** (p.ej. `3d/fox.glb`), y el archivo
|
||||
debe existir bajo `~/ComfyUI/input/3d/`. `Load3D` solo lista/carga ese directorio: si
|
||||
el GLB vive en `output/3D/`, cópialo a `input/3d/` antes (eso es I/O, fuera de esta
|
||||
función pura). Sin la copia el combo `model_file` solo ofrece `none`.
|
||||
- **No es output node**: `Load3D`/`Load3DAdvanced` renderizan en el frontend (Three.js)
|
||||
SIN ejecutar el grafo (no hace falta Queue). Si quieres mostrar un GLB que produce un
|
||||
pipeline al ejecutar, usa `Preview3D` (output node, requiere queue) — no es esta función.
|
||||
- **Requiere ComfyUI >= 0.26.0** (nodos nativos `Load3D`/`Load3DAdvanced`, módulo
|
||||
`comfy_extras.nodes_load_3d`). En versiones anteriores el server rechaza el workflow.
|
||||
- El flag `animation` elige la VARIANTE de nodo, no un modo "play": ambos visores ya
|
||||
reproducen las animaciones embebidas del modelo en el frontend. `Load3DAdvanced` aporta
|
||||
`viewport_state` (control fino de cámara), útil para inspeccionar la órbita de un modelo
|
||||
animado; `Load3D` da además un preview `image` del visor.
|
||||
- Pura: sólo arma el dict, no toca red ni disco ni valida contra el server. Valida con
|
||||
`comfyui_validate_workflow` si dudas de que el nodo exista en tu versión.
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Construye el workflow minimo de un visor 3D nativo de ComfyUI (Load3D).
|
||||
|
||||
ComfyUI 0.26.0 trae el nodo nativo `Load3D` (display "Load 3D & Animation",
|
||||
`comfy_extras.nodes_load_3d`, categoria `3d`): un visor Three.js embebido que
|
||||
renderiza un GLB/GLTF/OBJ/FBX/STL/PLY **en el frontend, sin ejecutar el grafo**
|
||||
(no es output node). Sirve para VER un mesh ya existente con su textura/color,
|
||||
orbitando con el raton, dentro de un nodo de la UI.
|
||||
|
||||
Este builder devuelve el dict API-format (un unico nodo) cargable en la UI con
|
||||
`comfyui_load_workflow_ui`. La variante se elige con `animation`:
|
||||
|
||||
- animation=False -> `Load3D` (visor estandar; input `image` de estado del
|
||||
visor; el usado en el report 0079). Reproduce animaciones embebidas del
|
||||
modelo en el frontend.
|
||||
- animation=True -> `Load3DAdvanced` (display "Load 3D (Advanced)"; input
|
||||
`viewport_state` en vez de `image`): mismo visor con control avanzado de
|
||||
camara/viewport, mejor para inspeccionar la orbita de un modelo animado.
|
||||
|
||||
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||
|
||||
GOTCHA: `Load3D`/`Load3DAdvanced` solo listan/cargan archivos que esten bajo
|
||||
`~/ComfyUI/input/3d/`. `model_file` debe ser la ruta RELATIVA a `input/`
|
||||
(p.ej. "3d/fox.glb"). Copiar el GLB ahi es I/O, fuera de esta funcion pura.
|
||||
"""
|
||||
|
||||
|
||||
def comfyui_build_view_3d_workflow(
|
||||
model_file: str,
|
||||
*,
|
||||
animation: bool = False,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
) -> dict:
|
||||
"""Monta el API-format de un visor 3D minimo para un GLB/GLTF/OBJ existente.
|
||||
|
||||
Args:
|
||||
model_file: ruta del modelo RELATIVA al `input/` del servidor ComfyUI
|
||||
(p.ej. "3d/fox_mv_textured.glb"). El archivo debe existir ya bajo
|
||||
`~/ComfyUI/input/3d/` para que el visor lo cargue.
|
||||
animation: si True usa `Load3DAdvanced` (control avanzado de
|
||||
camara/viewport, apto para inspeccionar modelos animados); si False
|
||||
(default) usa `Load3D`, el visor estandar del report 0079. Ambos
|
||||
reproducen animaciones embebidas del modelo en el frontend.
|
||||
keyword-only.
|
||||
width: ancho del viewport del nodo en px. keyword-only.
|
||||
height: alto del viewport del nodo en px. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict en API format con un unico nodo "1". Con animation=False:
|
||||
{"1": {"class_type": "Load3D", "inputs": {"model_file", "image",
|
||||
"width", "height"}}}; con animation=True el class_type es
|
||||
"Load3DAdvanced" y el segundo input es "viewport_state". Cargable con
|
||||
comfyui_load_workflow_ui (inyecta en la UI) o POSTeable a /prompt.
|
||||
"""
|
||||
if animation:
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "Load3DAdvanced",
|
||||
"inputs": {
|
||||
"model_file": model_file,
|
||||
"viewport_state": "",
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "Load3D",
|
||||
"inputs": {
|
||||
"model_file": model_file,
|
||||
"image": "",
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
wf = comfyui_build_view_3d_workflow("3d/fox_mv_textured.glb")
|
||||
print(json.dumps(wf, indent=2))
|
||||
wf_anim = comfyui_build_view_3d_workflow("3d/walk_cycle.glb", animation=True)
|
||||
print(json.dumps(wf_anim, indent=2))
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: comfyui_download_model
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_download_model(url: str, dest_subdir: str = 'checkpoints', *, comfyui_dir: str = '~/ComfyUI', filename: str | None = None, token: str | None = None, overwrite: bool = False, timeout_s: float = 1800.0) -> dict"
|
||||
description: "Descarga un checkpoint/LoRA/VAE a <comfyui_dir>/models/<dest_subdir>/<filename> por HTTP siguiendo redirects. Soporta Civitai (token via ?token= y header Authorization Bearer) y HuggingFace (URL directa). Valida que la respuesta NO sea HTML de error y que un .safetensors tenga cabecera valida, asi no deja modelos falsos de 2 KB. Impura: red (HTTP GET) + escritura en disco. Solo stdlib."
|
||||
tags: [comfyui, ml, image-generation, stable-diffusion, http, download, models, civitai, huggingface]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "os", "struct", "urllib.error", "urllib.parse", "urllib.request"]
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL directa de descarga (Civitai api/download/models/<versionId>, HuggingFace resolve, o cualquier HTTP que sirva el binario)."
|
||||
- name: dest_subdir
|
||||
desc: "Subcarpeta dentro de models/ (checkpoints, loras, vae, controlnet, ...). Default 'checkpoints'."
|
||||
- name: comfyui_dir
|
||||
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'."
|
||||
- name: filename
|
||||
desc: "Nombre destino. None lo deriva del Content-Disposition de la respuesta o del path de la URL."
|
||||
- name: token
|
||||
desc: "Token de API (Civitai). Se añade como ?token= y como header Authorization Bearer. None lo omite. No hardcodear secretos: pasar desde pass/vault."
|
||||
- name: overwrite
|
||||
desc: "Si False y el destino ya existe, no descarga y devuelve error. Default False."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la peticion HTTP en segundos. Default 1800 (30 min, modelos grandes)."
|
||||
output: "dict {ok: bool, path: str, size_bytes: int, error: str}. ok False si la respuesta era HTML de error, si un .safetensors no valida su cabecera, si la descarga es < 1 KB, o si fallo red/escritura. En esos casos NO deja basura en disco (limpia el .part)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_download_model.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from ml.comfyui_download_model import comfyui_download_model
|
||||
|
||||
# Civitai (token desde pass, nunca hardcodeado):
|
||||
import subprocess
|
||||
token = subprocess.run(["pass", "civitai/api-token"], capture_output=True, text=True).stdout.strip() or None
|
||||
out = comfyui_download_model(
|
||||
"https://civitai.com/api/download/models/128713",
|
||||
dest_subdir="checkpoints",
|
||||
token=token,
|
||||
)
|
||||
print(out["ok"], out["path"], out["size_bytes"])
|
||||
|
||||
# HuggingFace (URL directa resolve), sin token:
|
||||
out = comfyui_download_model(
|
||||
"https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors",
|
||||
dest_subdir="vae",
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas un modelo que ComfyUI no tiene aun: lo bajas a la carpeta
|
||||
correcta y luego llamas `comfyui_refresh_nodes_ui` para que aparezca en los
|
||||
combos de la UI sin recargar. Resuelve el sitio (`models/<dest_subdir>/`) y el
|
||||
nombre por ti, y rechaza descargas que en realidad son paginas de error.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Civitai exige login para muchos modelos**: sin `token` valido, Civitai
|
||||
responde con HTML (login/Cloudflare). La funcion lo detecta (content-type +
|
||||
sniff de los primeros bytes) y devuelve `ok=False` SIN guardar el HTML. Si ves
|
||||
ese error, falta o caduco el token.
|
||||
- La validacion de cabecera safetensors solo aplica a nombres `.safetensors`. Un
|
||||
`.ckpt`/`.pt`/`.bin` se valida solo por content-type, sniff HTML y tamaño minimo
|
||||
(1 KB). Para `.safetensors` ademas se comprueba la cabecera (8 bytes LE de
|
||||
longitud + `{`).
|
||||
- Descarga a `<destino>.part` y solo hace `os.replace` al destino final tras
|
||||
validar: una descarga corrupta o HTML no deja archivo final.
|
||||
- `overwrite=False` (default) NO re-descarga si el archivo ya existe: devuelve
|
||||
`ok=False` con el path existente. Pasa `overwrite=True` para forzar.
|
||||
- Modelos grandes (varios GB) tardan; sube `timeout_s` si hace falta. No abuses
|
||||
del disco: comprueba espacio antes de bajar checkpoints SDXL (~6-7 GB).
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Descarga un checkpoint / LoRA / VAE a la carpeta correcta de ComfyUI.
|
||||
|
||||
Descarga por HTTP a `<comfyui_dir>/models/<dest_subdir>/<filename>` siguiendo
|
||||
redirects. Soporta Civitai (`https://civitai.com/api/download/models/<versionId>`,
|
||||
token opcional via `?token=` y header `Authorization: Bearer`) y HuggingFace (URL
|
||||
directa de resolve). Antes de aceptar el archivo VALIDA que la respuesta no sea
|
||||
una pagina HTML de error (Cloudflare, login wall, 404 estilizado) y que, si el
|
||||
nombre termina en `.safetensors`, tenga una cabecera de safetensors valida. Asi
|
||||
no deja "modelos" que en realidad son HTML de 2 KB.
|
||||
|
||||
Funcion impura: hace red (HTTP GET) y escribe en disco. Solo stdlib.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_HTML_SNIFF = (b"<!doctype", b"<html", b"<head", b"<?xml")
|
||||
|
||||
|
||||
def _derive_filename(url: str, content_disposition: str) -> str:
|
||||
"""Deriva el nombre de archivo del Content-Disposition o, si no, de la URL."""
|
||||
if content_disposition:
|
||||
# filename="x" | filename=x | filename*=UTF-8''x
|
||||
for part in content_disposition.split(";"):
|
||||
part = part.strip()
|
||||
for key in ("filename*=", "filename="):
|
||||
if part.lower().startswith(key):
|
||||
raw = part[len(key):].strip().strip('"')
|
||||
if "''" in raw: # RFC 5987: UTF-8''<pct-encoded>
|
||||
raw = raw.split("''", 1)[1]
|
||||
name = urllib.parse.unquote(os.path.basename(raw))
|
||||
if name:
|
||||
return name
|
||||
name = os.path.basename(urllib.parse.urlparse(url).path)
|
||||
return name or "model.bin"
|
||||
|
||||
|
||||
def _is_valid_safetensors(path: str) -> bool:
|
||||
"""True si el archivo tiene cabecera de safetensors coherente.
|
||||
|
||||
Formato: 8 bytes little-endian con la longitud N del header JSON, seguidos de
|
||||
N bytes que empiezan por '{'. Rechaza HTML/errores disfrazados de .safetensors.
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
if size < 9:
|
||||
return False
|
||||
with open(path, "rb") as fh:
|
||||
n = struct.unpack("<Q", fh.read(8))[0]
|
||||
if n <= 0 or n > size - 8 or n > 100_000_000:
|
||||
return False
|
||||
return fh.read(1) == b"{"
|
||||
except Exception: # noqa: BLE001 — archivo ilegible = invalido
|
||||
return False
|
||||
|
||||
|
||||
def comfyui_download_model(
|
||||
url: str,
|
||||
dest_subdir: str = "checkpoints",
|
||||
*,
|
||||
comfyui_dir: str = "~/ComfyUI",
|
||||
filename: str | None = None,
|
||||
token: str | None = None,
|
||||
overwrite: bool = False,
|
||||
timeout_s: float = 1800.0,
|
||||
) -> dict:
|
||||
"""Descarga un modelo a `<comfyui_dir>/models/<dest_subdir>/<filename>`.
|
||||
|
||||
Args:
|
||||
url: URL directa de descarga (Civitai api/download, HuggingFace resolve,
|
||||
o cualquier HTTP que sirva el binario).
|
||||
dest_subdir: subcarpeta dentro de `models/` (checkpoints, loras, vae,
|
||||
controlnet, ...). Default "checkpoints".
|
||||
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
|
||||
filename: nombre destino del archivo. Si None, se deriva del
|
||||
Content-Disposition de la respuesta o del path de la URL.
|
||||
token: token de API (Civitai). Se añade como `?token=` y como header
|
||||
`Authorization: Bearer <token>`. None lo omite.
|
||||
overwrite: si False y el destino ya existe, no descarga y devuelve error.
|
||||
timeout_s: timeout de la peticion HTTP en segundos.
|
||||
|
||||
Returns:
|
||||
dict {ok: bool, path: str, size_bytes: int, error: str}. ok False si la
|
||||
respuesta era HTML de error, si un .safetensors no valida su cabecera, o
|
||||
si fallo la red/escritura. En esos casos no deja basura en disco.
|
||||
"""
|
||||
base = os.path.expanduser(comfyui_dir)
|
||||
dest_dir = os.path.join(base, "models", dest_subdir)
|
||||
|
||||
req_url = url
|
||||
headers = {"User-Agent": "fn-registry/comfyui_download_model"}
|
||||
if token:
|
||||
sep = "&" if "?" in req_url else "?"
|
||||
req_url = f"{req_url}{sep}token={urllib.parse.quote(token)}"
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
req = urllib.request.Request(req_url, headers=headers)
|
||||
tmp_path = None
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
disp = resp.headers.get("Content-Disposition", "")
|
||||
name = filename or _derive_filename(resp.geturl(), disp)
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
final_path = os.path.join(dest_dir, name)
|
||||
if os.path.exists(final_path) and not overwrite:
|
||||
return {
|
||||
"ok": False,
|
||||
"path": final_path,
|
||||
"size_bytes": os.path.getsize(final_path),
|
||||
"error": f"ya existe (overwrite=False): {final_path}",
|
||||
}
|
||||
|
||||
# Rechazo temprano por content-type HTML.
|
||||
if "text/html" in content_type.lower():
|
||||
return {
|
||||
"ok": False,
|
||||
"path": "",
|
||||
"size_bytes": 0,
|
||||
"error": (
|
||||
f"la respuesta es HTML (Content-Type: {content_type}), "
|
||||
"no un binario de modelo. Revisa la URL/token."
|
||||
),
|
||||
}
|
||||
|
||||
tmp_path = final_path + ".part"
|
||||
first = resp.read(512)
|
||||
# Sniff de los primeros bytes: HTML aunque el content-type mienta.
|
||||
low = first.lower().lstrip()
|
||||
if any(low.startswith(sig) for sig in _HTML_SNIFF):
|
||||
return {
|
||||
"ok": False,
|
||||
"path": "",
|
||||
"size_bytes": 0,
|
||||
"error": "la respuesta empieza con HTML (pagina de error/login), no un modelo.",
|
||||
}
|
||||
|
||||
size = 0
|
||||
with open(tmp_path, "wb") as fh:
|
||||
fh.write(first)
|
||||
size += len(first)
|
||||
while True:
|
||||
chunk = resp.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
size += len(chunk)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")[:300]
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": 0,
|
||||
"error": f"HTTP {exc.code} en {url}: {body}"}
|
||||
except Exception as exc: # noqa: BLE001 — red/DNS/escritura
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": 0,
|
||||
"error": f"fallo descargando {url}: {exc}"}
|
||||
|
||||
# Validacion de tamaño minimo (una pagina de error suele ser < 2 KB).
|
||||
if size < 1024:
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": size,
|
||||
"error": f"descarga sospechosamente pequeña ({size} bytes); probable error, no un modelo."}
|
||||
|
||||
# Validacion de cabecera safetensors si aplica.
|
||||
if name.endswith(".safetensors") and not _is_valid_safetensors(tmp_path):
|
||||
_cleanup(tmp_path)
|
||||
return {"ok": False, "path": "", "size_bytes": size,
|
||||
"error": f"{name} no tiene una cabecera safetensors valida; descarga corrupta o HTML disfrazado."}
|
||||
|
||||
os.replace(tmp_path, final_path)
|
||||
return {"ok": True, "path": final_path, "size_bytes": size, "error": ""}
|
||||
|
||||
|
||||
def _cleanup(path: str | None) -> None:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
out = comfyui_download_model(
|
||||
sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8188/",
|
||||
dest_subdir="checkpoints",
|
||||
filename="smoke_fake.safetensors",
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: comfyui_download_workflow
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_download_workflow(source: str, dest: str | None = None, *, server: str = \"127.0.0.1:8188\", civitai_token: str | None = None, hf_token: str | None = None, timeout: float = 30.0) -> dict"
|
||||
description: "Descarga un workflow ComfyUI desde CUALQUIER fuente (Google Drive, GitHub, Civitai, HuggingFace, URL directa o path local) y lo normaliza a API format. Dispatcher que detecta el tipo de fuente por la URL y delega: Drive via gdown/uc?export=download, GitHub via raw.githubusercontent.com, Civitai via API REST (resuelve downloadUrl, descomprime zip), HuggingFace via resolve/. Tras bajar: PNG/WebP -> comfyui_import_workflow_png; JSON -> comfyui_import_workflow_json (normaliza UI->API). Compone import_workflow_json + import_workflow_png. Impura: red + descompresion + disco."
|
||||
tags: [comfyui, ml, workflow, download, dispatcher, import]
|
||||
uses_functions: [comfyui_import_workflow_json_py_ml, comfyui_import_workflow_png_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: source
|
||||
desc: "URL (Google Drive con /d/<id> o ?id=, GitHub blob o raw, Civitai /api/download o /models/<id>, HuggingFace resolve, o URL directa a .json/.png/.webp) o ruta de un archivo local."
|
||||
- name: dest
|
||||
desc: "Ruta local donde guardar el archivo descargado. Si None, archivo temporal (se conserva y se reporta en 'path'). Para fuentes locales no copia: path = source. keyword-only por posicion 2 (acepta posicional)."
|
||||
- name: server
|
||||
desc: "host:port de ComfyUI, usado SOLO para mapear widgets cuando la fuente viene en formato UI graph (lo pasa a import_workflow_json). keyword-only."
|
||||
- name: civitai_token
|
||||
desc: "Token de Civitai (Bearer) para descargas restringidas/gated. keyword-only."
|
||||
- name: hf_token
|
||||
desc: "Token de HuggingFace (Bearer) para datasets privados. keyword-only."
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP en segundos. keyword-only."
|
||||
output: "dict {ok, workflow, source_type, path, format_in, error}. workflow = dict API format (vacio si ok=False); source_type = drive|github|civitai|huggingface|direct|local; path = ruta local descargada; format_in = api|ui_graph|png-prompt|png-workflow|zip. Nunca lanza: fallos devuelven ok=False con error."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_download_workflow.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_download_workflow import comfyui_download_workflow
|
||||
|
||||
# GitHub (cubiq, Apache-2.0) — baja el raw .json y lo deja en API format
|
||||
res = comfyui_download_workflow(
|
||||
"https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/main/ComfyUI_Simple/SDXL_simple.json"
|
||||
)
|
||||
# res == {"ok": True, "workflow": {...}, "source_type": "github",
|
||||
# "path": "/tmp/comfy_wf_xxx.json", "format_in": "ui_graph", "error": ""}
|
||||
|
||||
# Google Drive por share-url (extrae el file id; usa gdown si esta, si no descarga directa)
|
||||
res2 = comfyui_download_workflow("https://drive.google.com/file/d/<FILE_ID>/view", dest="/tmp/wf.json")
|
||||
|
||||
# El workflow resultante esta listo para validar/encolar:
|
||||
# from ml.comfyui_validate_workflow import comfyui_validate_workflow
|
||||
# comfyui_validate_workflow(res["workflow"])
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`. El bloque `__main__` baja el ejemplo de cubiq cuando lo ejecutas como script.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas la URL de un workflow ajeno (Drive de un creador, repo GitHub, página
|
||||
de Civitai, dataset de HuggingFace) y quieras un dict en API format sin pensar en el
|
||||
método de descarga ni en el formato. Es el punto de entrada único antes de
|
||||
`comfyui_validate_workflow` + `comfyui_resolve_workflow_deps` + `comfyui_submit_workflow`.
|
||||
Para una fuente que ya sabes que es JSON local/URL directa, `comfyui_import_workflow_json`
|
||||
basta; para un PNG suelto, `comfyui_import_workflow_png`. Este dispatcher es para
|
||||
"dame el workflow de esta URL, sea cual sea la fuente". Catálogo de fuentes: report 0080.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: hace HTTP GET (y gdown/unzip según fuente) + escribe a disco. Cualquier
|
||||
fallo de red/IO devuelve `{ok: False, error: ...}` (no lanza).
|
||||
- **Google Drive**: usa `gdown` si está instalado (maneja el aviso de virus-scan de
|
||||
archivos grandes). Sin gdown cae a `uc?export=download`, que solo sirve para
|
||||
archivos pequeños (un `.json` de workflow son KB); si Drive devuelve HTML (aviso
|
||||
de virus-scan o gated) la función pide instalar gdown. `pip install gdown` en el venv.
|
||||
- **Civitai**: descargas gated/early-access exigen `civitai_token` (Bearer). Sin token
|
||||
la respuesta puede ser HTML de login → error claro. Una página `/models/<id>` se
|
||||
resuelve via `/api/v1/models/<id>` tomando el primer file; para precisión pasa el
|
||||
`downloadUrl` directo (`/api/download/models/<version_id>`).
|
||||
- **GitHub**: una URL `github.com/.../blob/...` se reescribe a `raw.githubusercontent.com`
|
||||
automáticamente; si pasas la URL de la página HTML (no raw ni blob) puede bajar HTML
|
||||
→ error. Mejor pasar el raw o el blob.
|
||||
- **Formato de salida siempre API**: un PNG con chunk `prompt` (API) se usa directo; si
|
||||
solo trae el chunk `workflow` (UI graph) se normaliza vía import_workflow_json (necesita
|
||||
el server vivo para mapear widgets). Un UI graph `.json` se normaliza igual (best-effort:
|
||||
conexiones siempre; widgets sólo si el server responde).
|
||||
- **El workflow descargado es un secreto si trae credenciales/cookies** (raro en workflows,
|
||||
común en HAR): este caso es de workflows públicos; aun así no commitear el `path` temporal.
|
||||
- Fuentes con anti-bot fuerte (ComfyWorkflows.com, comfy.org/workflows con Cloudflare)
|
||||
pueden devolver 402/HTML a la descarga directa → requieren navegador (CDP). No cubiertas.
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Descarga un workflow ComfyUI desde CUALQUIER fuente y lo normaliza a API format.
|
||||
|
||||
Dispatcher: detecta el tipo de fuente por la URL/patron y delega la descarga, luego
|
||||
normaliza el resultado a API format reusando las dos funciones de import del registry
|
||||
(no reescribe la conversion):
|
||||
|
||||
- Google Drive (drive.google.com/.../d/<id> o uc?id=) -> gdown (si esta) o
|
||||
descarga directa uc?export=download -> import_workflow_json | import_workflow_png
|
||||
- GitHub (github.com/.../blob/... o raw.githubusercontent.com) -> raw URL del
|
||||
.json/.png -> import_workflow_json | import_workflow_png
|
||||
- Civitai (civitai.com/api/download/... o pagina /models/<id>) -> resuelve el
|
||||
downloadUrl via API REST, descarga el archivo (zip o json) -> import
|
||||
- HuggingFace (huggingface.co/datasets/.../resolve/...) -> import_workflow_json
|
||||
- URL directa .json/.png/.webp o path local -> import segun extension
|
||||
|
||||
El resultado SIEMPRE es API format (dict {node_id: {class_type, inputs}}), listo para
|
||||
comfyui_validate_workflow + comfyui_submit_workflow.
|
||||
|
||||
Compone comfyui_import_workflow_json + comfyui_import_workflow_png. Impura: red
|
||||
(HTTP GET / gdown), descompresion de zip y lectura/escritura de disco. Solo stdlib
|
||||
(urllib, json, zipfile, tempfile, re) + gdown opcional para Drive.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
from comfyui_import_workflow_json import comfyui_import_workflow_json # noqa: E402
|
||||
from comfyui_import_workflow_png import comfyui_import_workflow_png # noqa: E402
|
||||
|
||||
_UA = "Mozilla/5.0 (fn_registry comfyui_download_workflow)"
|
||||
|
||||
|
||||
def comfyui_download_workflow(
|
||||
source: str,
|
||||
dest: str | None = None,
|
||||
*,
|
||||
server: str = "127.0.0.1:8188",
|
||||
civitai_token: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Descarga un workflow de ComfyUI de cualquier fuente y lo normaliza a API format.
|
||||
|
||||
Args:
|
||||
source: URL (Google Drive, GitHub, Civitai, HuggingFace, o directa a
|
||||
.json/.png/.webp) o ruta de un archivo local.
|
||||
dest: ruta local donde guardar el archivo descargado. Si None, se usa un
|
||||
archivo temporal (que se conserva para trazabilidad y se reporta en
|
||||
'path'). Para fuentes locales no se copia: 'path' = source.
|
||||
server: host:port de ComfyUI, usado SOLO para mapear widgets cuando la
|
||||
fuente viene en formato UI graph (lo pasa a import_workflow_json).
|
||||
keyword-only.
|
||||
civitai_token: token de Civitai (Bearer) para descargas restringidas/gated.
|
||||
keyword-only.
|
||||
hf_token: token de HuggingFace (Bearer) para datasets privados. keyword-only.
|
||||
timeout: timeout HTTP en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, workflow, source_type, path, format_in, error}:
|
||||
- workflow: dict en API format (vacio si ok=False).
|
||||
- source_type: 'drive' | 'github' | 'civitai' | 'huggingface' |
|
||||
'direct' | 'local'.
|
||||
- path: ruta local del archivo descargado (o source si era local).
|
||||
- format_in: formato de origen detectado ('api', 'ui_graph',
|
||||
'png-prompt', 'png-workflow', 'zip').
|
||||
Nunca lanza: cualquier fallo de red/IO devuelve ok=False con error.
|
||||
"""
|
||||
source_type = _detect_source_type(source)
|
||||
try:
|
||||
if source_type == "local":
|
||||
local_path = source
|
||||
if not os.path.exists(local_path):
|
||||
return _err(source_type, f"no existe el archivo local {source!r}")
|
||||
elif source_type == "drive":
|
||||
local_path = _download_drive(source, dest, timeout)
|
||||
elif source_type == "civitai":
|
||||
local_path = _download_civitai(source, dest, civitai_token, timeout)
|
||||
else: # github | huggingface | direct
|
||||
url = _to_raw_url(source) if source_type == "github" else source
|
||||
token = hf_token if source_type == "huggingface" else None
|
||||
local_path = _download_url(url, dest, token, timeout)
|
||||
except _DownloadError as exc:
|
||||
return _err(source_type, str(exc))
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
return _err(source_type, f"fallo de descarga: {exc}")
|
||||
|
||||
# Si bajamos un zip (tipico de Civitai), extraer el primer workflow de dentro.
|
||||
if local_path.lower().endswith(".zip"):
|
||||
try:
|
||||
inner, fmt_hint = _extract_from_zip(local_path)
|
||||
except _DownloadError as exc:
|
||||
return _err(source_type, str(exc), path=local_path, fmt="zip")
|
||||
norm = _normalize(inner, server, timeout)
|
||||
norm["format_in"] = "zip"
|
||||
norm["source_type"] = source_type
|
||||
norm["path"] = local_path
|
||||
return norm
|
||||
|
||||
norm = _normalize(local_path, server, timeout)
|
||||
norm["source_type"] = source_type
|
||||
norm["path"] = local_path
|
||||
return norm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Deteccion + resolucion de URLs
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _detect_source_type(source: str) -> str:
|
||||
if not source.startswith(("http://", "https://")):
|
||||
return "local"
|
||||
host = urllib.parse.urlparse(source).netloc.lower()
|
||||
if "drive.google.com" in host or "docs.google.com" in host:
|
||||
return "drive"
|
||||
if "civitai.com" in host:
|
||||
return "civitai"
|
||||
if "github.com" in host or "githubusercontent.com" in host:
|
||||
return "github"
|
||||
if "huggingface.co" in host:
|
||||
return "huggingface"
|
||||
return "direct"
|
||||
|
||||
|
||||
def _to_raw_url(github_url: str) -> str:
|
||||
"""Convierte una URL github.com/.../blob/<branch>/<path> a raw.githubusercontent.com."""
|
||||
if "raw.githubusercontent.com" in github_url or "/raw/" in github_url:
|
||||
return github_url
|
||||
m = re.match(
|
||||
r"https://github\.com/([^/]+)/([^/]+)/blob/(.+)$", github_url
|
||||
)
|
||||
if m:
|
||||
user, repo, rest = m.groups()
|
||||
return f"https://raw.githubusercontent.com/{user}/{repo}/{rest}"
|
||||
return github_url # ya es raw o un patron no-blob: usar tal cual
|
||||
|
||||
|
||||
def _drive_id(url: str) -> str | None:
|
||||
m = re.search(r"/d/([A-Za-z0-9_-]+)", url) or re.search(r"[?&]id=([A-Za-z0-9_-]+)", url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Descargas por fuente
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _http_bytes(url: str, token: str | None, timeout: float) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _ext_from(url_or_name: str, content: bytes) -> str:
|
||||
low = url_or_name.lower().split("?")[0]
|
||||
for ext in (".json", ".png", ".webp", ".zip"):
|
||||
if low.endswith(ext):
|
||||
return ext
|
||||
if content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return ".png"
|
||||
if content[:4] == b"PK\x03\x04":
|
||||
return ".zip"
|
||||
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return ".webp"
|
||||
return ".json"
|
||||
|
||||
|
||||
def _save(content: bytes, dest: str | None, ext: str) -> str:
|
||||
if dest:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True)
|
||||
path = dest
|
||||
else:
|
||||
fd, path = tempfile.mkstemp(prefix="comfy_wf_", suffix=ext)
|
||||
os.close(fd)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
|
||||
def _download_url(url: str, dest: str | None, token: str | None, timeout: float) -> str:
|
||||
content = _http_bytes(url, token, timeout)
|
||||
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
|
||||
raise _DownloadError(
|
||||
f"la respuesta de {url!r} es HTML, no un workflow (gated/login o URL de pagina, no raw)"
|
||||
)
|
||||
return _save(content, dest, _ext_from(url, content))
|
||||
|
||||
|
||||
def _download_drive(source: str, dest: str | None, timeout: float) -> str:
|
||||
file_id = _drive_id(source)
|
||||
if not file_id:
|
||||
raise _DownloadError(f"no se pudo extraer el file id de Drive de {source!r}")
|
||||
# Camino 1: gdown (maneja el warning de virus-scan de archivos grandes).
|
||||
try:
|
||||
import gdown # type: ignore
|
||||
|
||||
out = dest or tempfile.mkstemp(prefix="comfy_wf_", suffix=".bin")[1]
|
||||
got = gdown.download(id=file_id, output=out, quiet=True)
|
||||
if got and os.path.exists(out) and os.path.getsize(out) > 0:
|
||||
return _retype_by_content(out)
|
||||
raise _DownloadError("gdown no devolvio archivo")
|
||||
except ImportError:
|
||||
pass # sin gdown: fallback urllib
|
||||
# Camino 2: descarga directa (sirve para archivos pequenos como un .json de workflow).
|
||||
url = f"https://drive.google.com/uc?export=download&id={file_id}"
|
||||
content = _http_bytes(url, None, timeout)
|
||||
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
|
||||
raise _DownloadError(
|
||||
"Drive devolvio HTML (archivo grande con aviso de virus-scan o gated). "
|
||||
"Instala gdown (pip install gdown) para este archivo."
|
||||
)
|
||||
return _save(content, dest, _ext_from(source, content))
|
||||
|
||||
|
||||
def _retype_by_content(path: str) -> str:
|
||||
"""Renombra un archivo .bin descargado a su extension real segun cabecera."""
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(16)
|
||||
ext = _ext_from(path, head)
|
||||
if path.lower().endswith(ext):
|
||||
return path
|
||||
new = os.path.splitext(path)[0] + ext
|
||||
os.replace(path, new)
|
||||
return new
|
||||
|
||||
|
||||
def _download_civitai(source: str, dest: str | None, token: str | None, timeout: float) -> str:
|
||||
download_url = source
|
||||
# Pagina de modelo civitai.com/models/<id> -> resolver el primer file via API v1.
|
||||
m = re.search(r"civitai\.com/models/(\d+)", source)
|
||||
if m and "/api/download/" not in source:
|
||||
api = f"https://civitai.com/api/v1/models/{m.group(1)}"
|
||||
meta = json.loads(_http_bytes(api, token, timeout))
|
||||
versions = meta.get("modelVersions") or []
|
||||
files = (versions[0].get("files") if versions else None) or []
|
||||
if not files:
|
||||
raise _DownloadError(f"el modelo Civitai {m.group(1)} no expone archivos descargables")
|
||||
download_url = files[0].get("downloadUrl") or ""
|
||||
if not download_url:
|
||||
raise _DownloadError("Civitai no devolvio downloadUrl para el modelo")
|
||||
content = _http_bytes(download_url, token, timeout)
|
||||
if content[:15].lstrip().startswith(b"<!DOCTYPE") or content[:6].lstrip().startswith(b"<html"):
|
||||
raise _DownloadError(
|
||||
"Civitai devolvio HTML (requiere login/token o el workflow es early-access). "
|
||||
"Pasa civitai_token."
|
||||
)
|
||||
return _save(content, dest, _ext_from(download_url, content))
|
||||
|
||||
|
||||
def _extract_from_zip(zip_path: str) -> tuple[str, str]:
|
||||
"""Extrae el primer .json/.png de un zip a un tmp y devuelve (ruta, hint)."""
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = [n for n in zf.namelist() if n.lower().endswith((".json", ".png", ".webp"))]
|
||||
if not names:
|
||||
raise _DownloadError(f"el zip {zip_path!r} no contiene .json ni .png de workflow")
|
||||
name = names[0]
|
||||
data = zf.read(name)
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
fd, out = tempfile.mkstemp(prefix="comfy_wf_zip_", suffix=ext)
|
||||
os.close(fd)
|
||||
with open(out, "wb") as f:
|
||||
f.write(data)
|
||||
return out, ext
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Normalizacion a API format (reusa las funciones de import del registry)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _normalize(path: str, server: str, timeout: float) -> dict:
|
||||
low = path.lower()
|
||||
if low.endswith((".png", ".webp")):
|
||||
res = comfyui_import_workflow_png(path, timeout=timeout)
|
||||
if not res.get("ok"):
|
||||
return {"ok": False, "workflow": {}, "format_in": "",
|
||||
"error": res.get("error", "PNG sin workflow embebido")}
|
||||
# Preferir el chunk 'prompt' (API format). Si solo hay UI graph, normalizarlo.
|
||||
if res.get("prompt"):
|
||||
return {"ok": True, "workflow": res["prompt"], "format_in": "png-prompt", "error": ""}
|
||||
ui = res.get("workflow") or {}
|
||||
if ui:
|
||||
tmp = _dump_tmp_json(ui)
|
||||
j = comfyui_import_workflow_json(tmp, server=server, timeout=timeout)
|
||||
return {"ok": j.get("ok", False), "workflow": j.get("workflow", {}),
|
||||
"format_in": "png-workflow", "error": j.get("error", "")}
|
||||
return {"ok": False, "workflow": {}, "format_in": "",
|
||||
"error": "PNG sin chunk prompt ni workflow"}
|
||||
# .json / sin extension -> import_workflow_json (passthrough API o normaliza UI)
|
||||
res = comfyui_import_workflow_json(path, server=server, timeout=timeout)
|
||||
fmt = res.get("format_detected", "")
|
||||
return {"ok": res.get("ok", False), "workflow": res.get("workflow", {}),
|
||||
"format_in": fmt, "error": res.get("error", "")}
|
||||
|
||||
|
||||
def _dump_tmp_json(obj: dict) -> str:
|
||||
fd, tmp = tempfile.mkstemp(prefix="comfy_wf_ui_", suffix=".json")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(obj, f)
|
||||
return tmp
|
||||
|
||||
|
||||
def _err(source_type: str, msg: str, *, path: str = "", fmt: str = "") -> dict:
|
||||
return {"ok": False, "workflow": {}, "source_type": source_type,
|
||||
"path": path, "format_in": fmt, "error": msg}
|
||||
|
||||
|
||||
class _DownloadError(Exception):
|
||||
"""Error de descarga interno, traducido a {ok: False, error} en la salida."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Smoke: baja un workflow real de cubiq (Apache-2.0) desde GitHub raw.
|
||||
url = (
|
||||
"https://raw.githubusercontent.com/cubiq/ComfyUI_Workflows/"
|
||||
"main/ComfyUI_Simple/SDXL_simple.json"
|
||||
)
|
||||
out = comfyui_download_workflow(url)
|
||||
print(json.dumps({k: v for k, v in out.items() if k != "workflow"}, indent=2))
|
||||
print("nodos:", len(out.get("workflow", {})))
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: comfyui_fetch_output_image
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_fetch_output_image(filename: str, *, subfolder: str = \"\", type_: str = \"output\", server: str = \"127.0.0.1:8188\", dest_dir: str = \".\", timeout: float = 60.0) -> dict"
|
||||
description: "Descarga un PNG generado por ComfyUI via GET /view?filename=&subfolder=&type= a disco local. comfyui_wait_result solo devuelve metadata (filename/subfolder/type); esta funcion baja el archivo real. Impura: HTTP GET + escritura en disco, solo stdlib."
|
||||
tags: [comfyui, ml, image-generation, download, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: filename
|
||||
desc: "Nombre del archivo en el servidor (ej. 'comfy_00001_.png'), tal como lo reporta comfyui_wait_result en outputs[node].images[].filename."
|
||||
- name: subfolder
|
||||
desc: "Subcarpeta dentro de la carpeta del servidor (vacia por defecto). keyword-only."
|
||||
- name: type_
|
||||
desc: "Tipo de carpeta del servidor: 'output', 'temp' o 'input'. keyword-only."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
|
||||
- name: dest_dir
|
||||
desc: "Directorio local donde guardar la imagen; se crea si no existe. keyword-only."
|
||||
- name: timeout
|
||||
desc: "Timeout de la peticion HTTP en segundos. keyword-only."
|
||||
output: "dict {ok, path, size_bytes, error}. path = ruta local del PNG guardado, size_bytes = bytes descargados. Si falla, ok=False y error explica (HTTP/conexion/escritura)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_fetch_output_image.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||
|
||||
# Tras comfyui_submit_workflow + comfyui_wait_result, baja el PNG al disco
|
||||
res = comfyui_fetch_output_image("comfy_00001_.png", dest_dir="/tmp/comfy_out")
|
||||
# res == {"ok": True, "path": "/tmp/comfy_out/comfy_00001_.png", "size_bytes": 372027, "error": ""}
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Despues de generar una imagen (submit + wait), cuando necesites el PNG real en
|
||||
disco (no solo su nombre): para abrirlo, mostrarlo, post-procesarlo o moverlo a
|
||||
un vault. Toma `filename`/`subfolder`/`type` directo de la entrada `images[]` que
|
||||
devuelve `comfyui_wait_result` por nodo SaveImage.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: hace HTTP GET al servidor y escribe en disco. Requiere el servidor vivo.
|
||||
- `type_` debe coincidir con la carpeta real: SaveImage escribe en "output",
|
||||
PreviewImage en "temp". Si pasas el type equivocado, el servidor responde 404.
|
||||
- El nombre local es `basename(filename)` dentro de `dest_dir` (no recrea la
|
||||
estructura de subfolder en local).
|
||||
- No reintenta: si el servidor esta reiniciandose, devuelve error de conexion;
|
||||
reintenta tu desde el caller.
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Descarga un PNG generado por ComfyUI via GET /view a disco local.
|
||||
|
||||
comfyui_wait_result devuelve solo metadata (node_id -> {images: [{filename,
|
||||
subfolder, type}]}); esta funcion baja el archivo real al disco local para
|
||||
poder abrirlo, mostrarlo o procesarlo.
|
||||
|
||||
Impura: red (HTTP GET) + escritura en disco. Solo stdlib (urllib, os).
|
||||
"""
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def comfyui_fetch_output_image(
|
||||
filename: str,
|
||||
*,
|
||||
subfolder: str = "",
|
||||
type_: str = "output",
|
||||
server: str = "127.0.0.1:8188",
|
||||
dest_dir: str = ".",
|
||||
timeout: float = 60.0,
|
||||
) -> dict:
|
||||
"""Baja una imagen del servidor ComfyUI a un directorio local.
|
||||
|
||||
Args:
|
||||
filename: nombre del archivo en el servidor (ej. "comfy_00001_.png"),
|
||||
tal como lo reporta comfyui_wait_result en outputs[node].images.
|
||||
subfolder: subcarpeta dentro de la carpeta del servidor (vacia por
|
||||
defecto). keyword-only.
|
||||
type_: tipo de carpeta del servidor: "output", "temp" o "input".
|
||||
keyword-only.
|
||||
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||
dest_dir: directorio local donde guardar la imagen; se crea si no existe.
|
||||
keyword-only.
|
||||
timeout: timeout de la peticion HTTP en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, path, size_bytes, error}. path = ruta local del PNG guardado;
|
||||
size_bytes = tamano descargado. Si falla, ok=False y error explica.
|
||||
"""
|
||||
qs = urllib.parse.urlencode(
|
||||
{"filename": filename, "subfolder": subfolder, "type": type_}
|
||||
)
|
||||
url = f"http://{server}/view?{qs}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
blob = resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")[:200]
|
||||
return {"ok": False, "path": "", "size_bytes": 0,
|
||||
"error": f"HTTP {exc.code} en {url}: {body}"}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"ok": False, "path": "", "size_bytes": 0,
|
||||
"error": f"no se pudo conectar a {url}: {exc.reason}"}
|
||||
try:
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
out_path = os.path.join(dest_dir, os.path.basename(filename))
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(blob)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "path": "", "size_bytes": 0,
|
||||
"error": f"no se pudo escribir en {dest_dir!r}: {exc}"}
|
||||
return {"ok": True, "path": out_path, "size_bytes": len(blob), "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
res = comfyui_fetch_output_image("comfy_00001_.png", dest_dir="/tmp")
|
||||
print(json.dumps(res, indent=2))
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: comfyui_fetch_output_mesh
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_fetch_output_mesh(prompt_id: str, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, timeout: float = 120.0) -> dict"
|
||||
description: "Localiza y descarga la malla 3D producida por un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_image pero para mallas: el nodo SaveGLB expone su salida en GET /history/{prompt_id} bajo la clave '3d' (no 'images'). Localiza el primer .glb/.obj/.ply/.gltf/.fbx/.stl, lo baja via GET /view y opcionalmente lo escribe en dest. Impura: HTTP GET + escritura en disco, solo stdlib."
|
||||
tags: [comfyui, ml, img-to-3d, hunyuan3d, mesh, download, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: prompt_id
|
||||
desc: "id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo SaveGLB ya termino (usa comfyui_wait_result antes si dudas)."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
|
||||
- name: dest
|
||||
desc: "Ruta destino. Si None, escribe el basename de la malla en el cwd. Si es un directorio (o termina en separador), escribe el basename dentro. Si es una ruta de archivo, escribe ahi. keyword-only."
|
||||
- name: timeout
|
||||
desc: "Timeout de cada peticion HTTP en segundos. keyword-only."
|
||||
output: "dict {ok, path, format, bytes, error}. path = ruta local del archivo de malla guardado, format = extension sin punto (ej. 'glb'), bytes = bytes descargados. Si falla, ok=False y error explica (sin malla en history, HTTP, conexion o escritura)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_fetch_output_mesh.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_fetch_output_mesh import comfyui_fetch_output_mesh
|
||||
|
||||
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow imagen->3D,
|
||||
# baja el .glb al disco (el SaveGLB lo expone en /history bajo la clave "3d").
|
||||
res = comfyui_fetch_output_mesh("2817f111-e21b-4672-95e7-5bec4314c4a7", dest="/tmp/meshes")
|
||||
# res == {"ok": True, "path": "/tmp/meshes/3d_robot_mesh_00001_.glb",
|
||||
# "format": "glb", "bytes": 60051544, "error": ""}
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Despues de reconstruir una malla 3D (submit + wait de un workflow Hunyuan3D),
|
||||
cuando necesites el archivo .glb/.obj/.ply real en disco (no solo su nombre): para
|
||||
abrirlo en un visor, post-procesarlo (decimar, recolorear) o moverlo a un vault.
|
||||
Para el flujo completo desde una imagen en disco usa el pipeline
|
||||
`comfyui_image_to_3d_oneshot`, que ya llama a esta funcion al final.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: hace HTTP GET a /history y /view y escribe en disco. Requiere el server
|
||||
vivo y que el prompt YA haya terminado (usa `comfyui_wait_result` antes).
|
||||
- El SaveGLB expone la malla bajo la clave `"3d"` en los outputs, NO bajo
|
||||
`"images"` — por eso `comfyui_fetch_output_image` no sirve para mallas.
|
||||
- El history se purga al reiniciar el server: si el prompt ya no esta, devuelve
|
||||
`ok=False` con "no esta en /history". No reintenta; reintenta tu desde el caller.
|
||||
- Toma el PRIMER archivo de malla que encuentra (prioriza la clave "3d"). Si un
|
||||
workflow exporta varios formatos, baja solo uno; para los demas, llama otra vez
|
||||
o usa GET /view con el filename concreto.
|
||||
- `dest` se interpreta: None -> cwd; directorio -> dentro; archivo -> esa ruta.
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Localiza y descarga la malla 3D producida por un workflow ComfyUI a disco local.
|
||||
|
||||
Hermana de comfyui_fetch_output_image, pero para mallas 3D: el nodo SaveGLB de un
|
||||
workflow Hunyuan3D expone su salida en GET /history/{prompt_id} bajo la clave "3d"
|
||||
(no "images"), con {filename, subfolder, type}. Esta funcion lee ese history,
|
||||
localiza el primer archivo de malla (.glb/.obj/.ply/.gltf/.fbx/.stl/.usdz), lo baja
|
||||
via GET /view a disco local y, opcionalmente, lo escribe en `dest`.
|
||||
|
||||
Impura: red (HTTP GET a /history y /view) + escritura en disco. Solo stdlib.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_MESH_EXTS = (".glb", ".gltf", ".obj", ".ply", ".fbx", ".stl", ".usdz", ".splat")
|
||||
|
||||
|
||||
def _find_mesh_output(outputs: dict) -> dict | None:
|
||||
"""Busca en los outputs de /history el primer archivo de malla 3D.
|
||||
|
||||
Recorre cada nodo y cada lista de su output; el SaveGLB usa la clave "3d",
|
||||
pero se acepta cualquier lista de dicts con "filename" de extension de malla.
|
||||
Devuelve {filename, subfolder, type} o None si no hay ninguno.
|
||||
"""
|
||||
# Prioriza la clave canonica "3d"; si no, cualquier lista con filename de malla.
|
||||
for prefer in (True, False):
|
||||
for node_out in outputs.values():
|
||||
if not isinstance(node_out, dict):
|
||||
continue
|
||||
for key, items in node_out.items():
|
||||
if prefer and key != "3d":
|
||||
continue
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
fn = item.get("filename", "")
|
||||
if fn.lower().endswith(_MESH_EXTS):
|
||||
return {
|
||||
"filename": fn,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_dest(dest: str | None, filename: str) -> str:
|
||||
"""Resuelve la ruta local destino a partir de `dest` y el basename remoto."""
|
||||
base = os.path.basename(filename)
|
||||
if dest is None:
|
||||
return os.path.join(os.getcwd(), base)
|
||||
expanded = os.path.expanduser(dest)
|
||||
if os.path.isdir(expanded) or expanded.endswith(os.sep):
|
||||
return os.path.join(expanded, base)
|
||||
return expanded
|
||||
|
||||
|
||||
def comfyui_fetch_output_mesh(
|
||||
prompt_id: str,
|
||||
*,
|
||||
server: str = "127.0.0.1:8188",
|
||||
dest: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict:
|
||||
"""Descarga la malla 3D de un prompt ComfyUI ya ejecutado a disco local.
|
||||
|
||||
Args:
|
||||
prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo
|
||||
nodo SaveGLB ya termino (usa comfyui_wait_result antes si dudas).
|
||||
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||
dest: ruta destino. Si None, escribe el basename de la malla en el cwd.
|
||||
Si es un directorio (o termina en separador), escribe el basename
|
||||
dentro. Si es una ruta de archivo, escribe ahi. keyword-only.
|
||||
timeout: timeout de cada peticion HTTP en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, path, format, bytes, error}. path = ruta local del archivo de
|
||||
malla guardado; format = extension sin punto (ej. "glb"); bytes = tamano
|
||||
descargado. Si falla, ok=False y error explica (sin malla en history,
|
||||
HTTP, conexion o escritura).
|
||||
"""
|
||||
hist_url = f"http://{server}/history/{prompt_id}"
|
||||
try:
|
||||
with urllib.request.urlopen(hist_url, timeout=timeout) as resp:
|
||||
hist = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")[:200]
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"HTTP {exc.code} en {hist_url}: {body}"}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"no se pudo conectar a {hist_url}: {exc.reason}"}
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"respuesta no es JSON valido desde {hist_url}: {exc}"}
|
||||
|
||||
entry = hist.get(prompt_id)
|
||||
if not entry:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"prompt_id {prompt_id} no esta en /history (¿no termino o se purgo?)"}
|
||||
outputs = entry.get("outputs", {})
|
||||
mesh = _find_mesh_output(outputs)
|
||||
if mesh is None:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"sin archivo de malla 3D en los outputs de {prompt_id}"}
|
||||
|
||||
qs = urllib.parse.urlencode({
|
||||
"filename": mesh["filename"],
|
||||
"subfolder": mesh["subfolder"],
|
||||
"type": mesh["type"],
|
||||
})
|
||||
view_url = f"http://{server}/view?{qs}"
|
||||
try:
|
||||
with urllib.request.urlopen(view_url, timeout=timeout) as resp:
|
||||
blob = resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")[:200]
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"HTTP {exc.code} en {view_url}: {body}"}
|
||||
except urllib.error.URLError as exc:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"no se pudo conectar a {view_url}: {exc.reason}"}
|
||||
|
||||
out_path = _resolve_dest(dest, mesh["filename"])
|
||||
try:
|
||||
parent = os.path.dirname(out_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(blob)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||
"error": f"no se pudo escribir en {out_path!r}: {exc}"}
|
||||
|
||||
fmt = os.path.splitext(mesh["filename"])[1].lstrip(".").lower()
|
||||
return {"ok": True, "path": out_path, "format": fmt, "bytes": len(blob), "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
pid = sys.argv[1] if len(sys.argv) > 1 else "00000000-0000-0000-0000-000000000000"
|
||||
res = comfyui_fetch_output_mesh(pid, dest="/tmp/comfy_mesh")
|
||||
print(json.dumps(res, indent=2))
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: comfyui_generate_views_from_image
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_generate_views_from_image(image_name: str, *, method: str = \"auto\", server: str = \"127.0.0.1:8188\", azimuths: tuple = (90, 180, 270), elevation: float = 0.0, dest_dir: str | None = None, validate_only: bool = False, wait_timeout: float = 300.0, timeout: float = 30.0) -> dict"
|
||||
description: "Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista de ComfyUI. Usa los sintetizadores NATIVOS StableZero123 (StableZero123_Conditioning_Batched, control de azimuth) o SV3D (orbita de 21 frames); en 8 GB cabe zero123 para sintesis de vistas. HONESTA: consulta /object_info, comprueba que el nodo Y su checkpoint estan instalados, y SOLO encola si hay camino viable; si no, devuelve {ok: False, reason} con la accion para habilitarlo SIN tocar la GPU. Compone object_info + submit + wait + fetch_output_image. Impura: HTTP + disco."
|
||||
tags: [comfyui, ml, img-to-3d, novel-view, multiview, stablezero123, sv3d]
|
||||
uses_functions: [comfyui_object_info_py_ml, comfyui_validate_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: image_name
|
||||
desc: "Nombre del archivo de imagen (vista frontal) en el input/ del servidor ComfyUI. Debe existir ya (subelo con POST /upload/image)."
|
||||
- name: method
|
||||
desc: "'zero123' (StableZero123, control directo de azimuth), 'sv3d' (orbita SVD, mejor consistencia) o 'auto' (elige el primero cuyo nodo+checkpoint esten instalados, prefiriendo zero123). keyword-only."
|
||||
- name: server
|
||||
desc: "host:port de ComfyUI. keyword-only."
|
||||
- name: azimuths
|
||||
desc: "Angulos (grados) de las vistas a generar; 90=right, 180=back, 270=left (0=front la aporta el caller). Se asumen equiespaciados para el batch. keyword-only."
|
||||
- name: elevation
|
||||
desc: "Elevacion de camara en grados para todas las vistas. keyword-only."
|
||||
- name: dest_dir
|
||||
desc: "Carpeta local donde descargar las vistas generadas. Si None, no se descargan (solo se devuelven los nombres del output del servidor). keyword-only."
|
||||
- name: validate_only
|
||||
desc: "Si True, construye el workflow y lo valida contra /object_info SIN encolar ni tocar la GPU; devuelve el veredicto estructural (valid, missing_nodes, missing_models). Util para comprobar viabilidad antes de comprometer GPU. keyword-only."
|
||||
- name: wait_timeout
|
||||
desc: "Timeout de espera de la generacion en segundos. keyword-only."
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP por request en segundos. keyword-only."
|
||||
output: "dict. Viable: {ok: True, method, views: {back, left, right -> ruta/nombre}, prompt_id, available, reason: '', error: ''}. validate_only=True (no encola): {ok: <valido>, method, validated: True, valid, missing_nodes, missing_models, available, ...}. Sin nodo+modelo viable (stub honesto, NO encola): {ok: False, method, views: {}, reason: '<que falta y como instalarlo>', available: {nodes, ckpts, ckpt_combo}, error: ''}. Fallos de red/encolado: ok=False con error."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_generate_views_from_image.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_generate_views_from_image import comfyui_generate_views_from_image
|
||||
|
||||
# Comprobar viabilidad SIN encolar (no toca GPU) — recomendado antes de generar
|
||||
chk = comfyui_generate_views_from_image("front.png", validate_only=True)
|
||||
# chk == {"ok": True, "method": "zero123", "validated": True, "valid": True,
|
||||
# "missing_nodes": [], "missing_models": [], ...} (si el ckpt esta instalado)
|
||||
|
||||
# 'front.png' debe estar ya en el input/ del servidor (POST /upload/image)
|
||||
res = comfyui_generate_views_from_image("front.png", method="auto", dest_dir="/tmp/views")
|
||||
|
||||
if res["ok"]:
|
||||
# res["views"] == {"right": "/tmp/views/novel_view_00001_.png",
|
||||
# "back": "/tmp/views/novel_view_00002_.png",
|
||||
# "left": "/tmp/views/novel_view_00003_.png"}
|
||||
# -> alimentan comfyui_build_image_to_3d_multiview_workflow junto a la frontal
|
||||
...
|
||||
else:
|
||||
# Stub honesto: el checkpoint del sintetizador no esta instalado.
|
||||
print(res["reason"]) # que falta + comando para instalarlo
|
||||
print(res["available"]) # {nodes: {...}, ckpts: {...}}
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). `./fn run` directo no aplica: la firma usa `*` (keyword-only). El bloque `__main__` ejecuta el caso sin modelos instalados y muestra el `ok=False` honesto.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas UNA sola imagen de un objeto y quieras reconstruir un 3D multi-vista
|
||||
mejor (cara trasera y laterales definidos, no alucinados). Genera con esta función las
|
||||
vistas back/left/right que faltan y pásalas, junto a la frontal, a
|
||||
`comfyui_build_image_to_3d_multiview_workflow` (Hunyuan3D-2mv). Si tienes fotos reales
|
||||
del objeto desde varios ángulos, NO la necesitas: úsalas directamente (mejor resultado;
|
||||
report 0073). Esta función es el camino sintético cuando solo hay 1 vista.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No finge resultados**: si el nodo o su checkpoint no están instalados, devuelve
|
||||
`{ok: False, reason: ...}` con el comando para habilitarlo y **NO encola nada** (no
|
||||
compite por la GPU). Estado en este equipo (24/06/2026, verificado contra `/object_info`):
|
||||
`stable_zero123.ckpt` **SÍ está instalado** → `method='zero123'` es viable y genera de
|
||||
verdad (el report 0073 lo daba por ausente; quedó desfasado). `sv3d_u.safetensors` NO
|
||||
está instalado y, además, su builder aún no existe (ver gotcha siguiente).
|
||||
- **`image_name` debe existir en `input/` ANTES de generar**: la función no sube la imagen
|
||||
(no la inventa). Si pasas un `image_name` que no está en el `input/` del servidor, el
|
||||
POST /prompt devuelve HTTP 400 (`prompt_outputs_failed_validation` de LoadImage) y la
|
||||
función lo propaga como `ok=False` con el body — comportamiento correcto, no un bug.
|
||||
Usa `validate_only=True` para comprobar el grafo sin necesidad de la imagen ni de GPU.
|
||||
- **`method='sv3d'` aún no tiene builder**: el camino SV3D (órbita de 21 frames) lanza
|
||||
`NotImplementedError` capturado → `ok=False` con error claro. Implementado: `zero123`
|
||||
(StableZero123_Conditioning_Batched). Se añadirá SV3D cuando el modelo esté disponible
|
||||
para probarlo (no especular: KISS).
|
||||
- **Encola trabajo de GPU** sólo en el camino viable: `comfyui_submit_workflow` dispara
|
||||
generación real. Respeta el aislamiento del server (coordina si otro agente lo usa).
|
||||
- **Vistas sintéticas ≠ fotos reales**: no son perfectamente ortogonales ni 100%
|
||||
consistentes; introducen ruido que el modelo mv puede amplificar. Para máxima fidelidad,
|
||||
fotos reales > síntesis. MV-Adapter (mejor sintetizador) es custom node, fuera de alcance.
|
||||
- `azimuths` se asume equiespaciado (el batch usa un incremento fijo). Mapeo de ángulo a
|
||||
nombre: 0=front, 90=right, 180=back, 270=left.
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista.
|
||||
|
||||
El camino imagen->3D de una sola vista deja indeterminada la cara trasera del objeto.
|
||||
El nodo `Hunyuan3Dv2ConditioningMultiView` reconstruye mucho mejor con varias vistas
|
||||
ortogonales, pero hace falta producirlas. ComfyUI 0.26.0 trae DOS sintetizadores de
|
||||
vistas NATIVOS (sin custom node), confirmados en /object_info:
|
||||
|
||||
- StableZero123 (`StableZero123_Conditioning_Batched`): control directo de azimuth
|
||||
por vista; un batch saca varias vistas en una pasada. Requiere el checkpoint
|
||||
`stable_zero123.ckpt` (~8.58 GB; cabe en 8 GB solo para SINTESIS de vistas).
|
||||
- SV3D (`SV3D_Conditioning`): orbita de 21 frames en una pasada, mejor consistencia;
|
||||
requiere `sv3d_u.safetensors`/`sv3d_p.safetensors` (~2.3 GB; modelo de video, mas
|
||||
exigente en VRAM).
|
||||
|
||||
Esta funcion es HONESTA sobre la viabilidad: consulta el servidor, comprueba que el
|
||||
nodo Y su checkpoint estan disponibles, y SOLO encola si hay un camino viable. Si no
|
||||
hay ningun (nodo + modelo) instalado, devuelve {ok: False, reason: ...} con la accion
|
||||
concreta para habilitarlo, SIN tocar la GPU (no encola nada). Asi no finge un resultado
|
||||
ni compite por la GPU cuando el modelo no esta.
|
||||
|
||||
Descartados por aislamiento: MV-Adapter y Zero123++ son custom nodes (no nativos); la
|
||||
regla prohibe instalarlos aqui.
|
||||
|
||||
Compone comfyui_object_info + comfyui_submit_workflow + comfyui_wait_result +
|
||||
comfyui_fetch_output_image. Impura: HTTP GET/POST + escritura en disco. Solo stdlib.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
from comfyui_object_info import comfyui_object_info # noqa: E402
|
||||
from comfyui_validate_workflow import comfyui_validate_workflow # noqa: E402
|
||||
from comfyui_submit_workflow import comfyui_submit_workflow # noqa: E402
|
||||
from comfyui_wait_result import comfyui_wait_result # noqa: E402
|
||||
from comfyui_fetch_output_image import comfyui_fetch_output_image # noqa: E402
|
||||
|
||||
# Checkpoint requerido por cada metodo (lo carga ImageOnlyCheckpointLoader).
|
||||
_METHOD_CKPT = {
|
||||
"zero123": "stable_zero123.ckpt",
|
||||
"sv3d": "sv3d_u.safetensors",
|
||||
}
|
||||
# azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller).
|
||||
_AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"}
|
||||
|
||||
|
||||
def comfyui_generate_views_from_image(
|
||||
image_name: str,
|
||||
*,
|
||||
method: str = "auto",
|
||||
server: str = "127.0.0.1:8188",
|
||||
azimuths: tuple = (90, 180, 270),
|
||||
elevation: float = 0.0,
|
||||
dest_dir: str | None = None,
|
||||
validate_only: bool = False,
|
||||
wait_timeout: float = 300.0,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Sintetiza vistas novel-view desde una imagen ya subida al input/ de ComfyUI.
|
||||
|
||||
Args:
|
||||
image_name: nombre del archivo de imagen en el `input/` del servidor
|
||||
(la vista frontal). Debe existir ya (subelo con POST /upload/image).
|
||||
method: 'zero123' (StableZero123, control de azimuth), 'sv3d' (orbita
|
||||
SVD) o 'auto' (elige el primero cuyo nodo+checkpoint esten
|
||||
instalados, prefiriendo zero123). keyword-only.
|
||||
server: host:port de ComfyUI. keyword-only.
|
||||
azimuths: angulos (grados) de las vistas a generar; 90=right, 180=back,
|
||||
270=left (0=front la aporta el caller). Se asumen equiespaciados para
|
||||
el batch. keyword-only.
|
||||
elevation: elevacion de camara en grados para todas las vistas.
|
||||
keyword-only.
|
||||
dest_dir: carpeta local donde descargar las vistas generadas. Si None, no
|
||||
se descargan (solo se devuelven los nombres del output del servidor).
|
||||
keyword-only.
|
||||
validate_only: si True, construye el workflow y lo VALIDA contra
|
||||
/object_info (comfyui_validate_workflow) SIN encolar ni tocar la GPU,
|
||||
devolviendo el veredicto estructural. Util para comprobar viabilidad
|
||||
antes de comprometer GPU (y para smoke sin generar). keyword-only.
|
||||
wait_timeout: timeout de espera de la generacion en segundos. keyword-only.
|
||||
timeout: timeout HTTP por request en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict. Si hay camino viable y se genera:
|
||||
{ok: True, method, views: {"back": <ruta/nombre>, "left": ..., "right": ...},
|
||||
prompt_id, available: {...}, reason: "", error: ""}.
|
||||
Con validate_only=True (no encola):
|
||||
{ok: <valido>, method, validated: True, valid, missing_nodes,
|
||||
missing_models, views: {}, available: {...}, reason: "", error: ""}.
|
||||
Si NINGUN nodo+modelo viable (stub honesto, no encola):
|
||||
{ok: False, method, views: {}, reason: "<que falta y como instalarlo>",
|
||||
available: {nodes: {...}, ckpts: {...}}, error: ""}.
|
||||
Cualquier fallo de red/encolado tambien devuelve ok=False con error.
|
||||
"""
|
||||
# 1. Inventario del servidor (impuro, solo lectura: NO encola, NO toca GPU).
|
||||
try:
|
||||
oi = comfyui_object_info(server=server, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _stub(method, f"no se pudo consultar /object_info de {server}: {exc}", error=str(exc))
|
||||
|
||||
nodes_present = {
|
||||
"zero123": "StableZero123_Conditioning_Batched" in oi,
|
||||
"sv3d": "SV3D_Conditioning" in oi,
|
||||
}
|
||||
ckpts = _checkpoint_combo(oi)
|
||||
ckpts_present = {m: (_METHOD_CKPT[m] in ckpts) for m in _METHOD_CKPT}
|
||||
available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts}
|
||||
|
||||
# 2. Elegir metodo viable (nodo + checkpoint presentes).
|
||||
order = [method] if method in _METHOD_CKPT else ["zero123", "sv3d"]
|
||||
chosen = next(
|
||||
(m for m in order if nodes_present.get(m) and ckpts_present.get(m)),
|
||||
None,
|
||||
)
|
||||
if chosen is None:
|
||||
return _stub(
|
||||
method,
|
||||
_why_unavailable(order, nodes_present, ckpts_present),
|
||||
available=available,
|
||||
)
|
||||
|
||||
# 3. Construir el workflow.
|
||||
try:
|
||||
wf = _build_views_workflow(image_name, chosen, ckpts[_method_ckpt_key(chosen, ckpts)],
|
||||
azimuths, elevation)
|
||||
except NotImplementedError as exc:
|
||||
return _stub(chosen, str(exc), available=available, error=str(exc))
|
||||
|
||||
# 3a. Modo validate_only: valida contra /object_info SIN encolar (no toca GPU).
|
||||
if validate_only:
|
||||
val = comfyui_validate_workflow(wf, server=server, timeout=timeout)
|
||||
return {"ok": bool(val.get("valid")), "method": chosen, "validated": True,
|
||||
"valid": val.get("valid"), "missing_nodes": val.get("missing_nodes", []),
|
||||
"missing_models": val.get("missing_models", []), "views": {},
|
||||
"available": available, "reason": "", "error": val.get("error", "")}
|
||||
|
||||
# 3b. Encolar y generar (solo si hay camino viable y NO es validate_only).
|
||||
try:
|
||||
sub = comfyui_submit_workflow(wf, server=server, timeout=timeout)
|
||||
prompt_id = sub.get("prompt_id")
|
||||
if not prompt_id:
|
||||
return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available)
|
||||
comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
|
||||
views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout)
|
||||
return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id,
|
||||
"available": available, "reason": "", "error": ""}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _stub(chosen, f"fallo al generar vistas: {exc}", available=available, error=str(exc))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _checkpoint_combo(oi: dict) -> list:
|
||||
"""Lista de checkpoints que el servidor ofrece a ImageOnlyCheckpointLoader."""
|
||||
for node in ("ImageOnlyCheckpointLoader", "CheckpointLoaderSimple"):
|
||||
spec = (oi.get(node) or {}).get("input", {}).get("required", {})
|
||||
decl = spec.get("ckpt_name")
|
||||
if isinstance(decl, list) and decl and isinstance(decl[0], list):
|
||||
return list(decl[0])
|
||||
return []
|
||||
|
||||
|
||||
def _method_ckpt_key(method: str, ckpts: list) -> int:
|
||||
return ckpts.index(_METHOD_CKPT[method])
|
||||
|
||||
|
||||
def _why_unavailable(order, nodes_present, ckpts_present) -> str:
|
||||
parts = []
|
||||
for m in order:
|
||||
if m not in _METHOD_CKPT:
|
||||
continue
|
||||
if not nodes_present.get(m):
|
||||
parts.append(f"{m}: nodo nativo ausente en el servidor")
|
||||
elif not ckpts_present.get(m):
|
||||
ck = _METHOD_CKPT[m]
|
||||
parts.append(
|
||||
f"{m}: nodo OK pero falta el checkpoint '{ck}'. "
|
||||
f"Instalalo con comfyui_download_model(<url_{m}>, dest_subdir='checkpoints')"
|
||||
)
|
||||
return ("Ningun sintetizador de vistas nativo viable en 8 GB esta listo. "
|
||||
+ " | ".join(parts) + ". Alternativa: aporta fotos reales del objeto "
|
||||
"(front/left/back/right) y usa comfyui_build_image_to_3d_multiview_workflow directamente.")
|
||||
|
||||
|
||||
def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation) -> dict:
|
||||
"""Workflow batched de sintesis de vistas. Hoy implementado para StableZero123."""
|
||||
if method == "sv3d":
|
||||
raise NotImplementedError(
|
||||
"el builder SV3D (orbita de 21 frames) no esta implementado todavia; usa method='zero123'"
|
||||
)
|
||||
azs = sorted(azimuths)
|
||||
start = azs[0]
|
||||
increment = (azs[1] - azs[0]) if len(azs) > 1 else 90
|
||||
batch = len(azs)
|
||||
return {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
|
||||
"2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}},
|
||||
"3": {
|
||||
"class_type": "StableZero123_Conditioning_Batched",
|
||||
"inputs": {
|
||||
"clip_vision": ["2", 1],
|
||||
"init_image": ["1", 0],
|
||||
"vae": ["2", 2],
|
||||
"width": 256,
|
||||
"height": 256,
|
||||
"batch_size": batch,
|
||||
"elevation": elevation,
|
||||
"azimuth": start,
|
||||
"elevation_batch_increment": 0.0,
|
||||
"azimuth_batch_increment": increment,
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 0, "steps": 20, "cfg": 4.0, "sampler_name": "euler",
|
||||
"scheduler": "normal", "denoise": 1.0,
|
||||
"model": ["2", 0], "positive": ["3", 0], "negative": ["3", 1],
|
||||
"latent_image": ["3", 2],
|
||||
},
|
||||
},
|
||||
"5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0], "vae": ["2", 2]}},
|
||||
"6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "filename_prefix": "novel_view"}},
|
||||
}
|
||||
|
||||
|
||||
def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict:
|
||||
"""Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
url = f"http://{server}/history/{prompt_id}"
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
hist = json.load(resp)
|
||||
outputs = (hist.get(prompt_id) or {}).get("outputs", {})
|
||||
images = []
|
||||
for node_out in outputs.values():
|
||||
images.extend(node_out.get("images", []))
|
||||
azs = sorted(azimuths)
|
||||
views = {}
|
||||
for img, az in zip(images, azs):
|
||||
name = _AZIMUTH_NAME.get(az, f"az{az}")
|
||||
if dest_dir:
|
||||
got = comfyui_fetch_output_image(
|
||||
img["filename"], subfolder=img.get("subfolder", ""),
|
||||
type_=img.get("type", "output"), server=server, dest_dir=dest_dir, timeout=60.0,
|
||||
)
|
||||
views[name] = got.get("path", img["filename"])
|
||||
else:
|
||||
views[name] = img["filename"]
|
||||
return views
|
||||
|
||||
|
||||
def _stub(method, reason, *, available=None, error="") -> dict:
|
||||
return {"ok": False, "method": method, "views": {}, "reason": reason,
|
||||
"available": available or {}, "error": error}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
# Smoke ligero: valida el camino sin encolar (no toca GPU). Si el checkpoint
|
||||
# del sintetizador no esta instalado, devuelve el stub honesto ok=False.
|
||||
res = comfyui_generate_views_from_image("front.png", validate_only=True)
|
||||
print(json.dumps({k: v for k, v in res.items() if k != "available"}, indent=2))
|
||||
print("available:", json.dumps(res.get("available", {}).get("ckpts", {})))
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: comfyui_import_workflow_json
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_import_workflow_json(source: str, *, server: str = \"127.0.0.1:8188\", timeout: float = 15.0) -> dict"
|
||||
description: "Lee un workflow ComfyUI desde una URL (http/https) o un path local y lo normaliza a API format. Si viene en formato UI graph ({nodes, links}) lo convierte a API format usando /object_info para mapear los widgets; si ya es API format lo devuelve tal cual. Omite los nodos virtuales del editor (Note, MarkdownNote, PrimitiveNode, Reroute) tal como hace ComfyUI al pasar UI->API: resuelve los Reroute reconectando la conexion directa origen->destino e inyecta los PrimitiveNode como valor de widget en el consumidor. Compone comfyui_object_info. Impura: HTTP GET / lectura de disco."
|
||||
tags: [comfyui, ml, import, workflow, stable-diffusion]
|
||||
uses_functions: [comfyui_object_info_py_ml]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: source
|
||||
desc: "URL http(s) de un JSON de workflow (OpenArt, ComfyWorkflows, raw GitHub...) o ruta de un archivo local."
|
||||
- name: server
|
||||
desc: "host:port de ComfyUI usado SOLO para mapear los valores de widget cuando la fuente viene en formato UI graph. keyword-only."
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP en segundos. keyword-only."
|
||||
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_import_workflow_json import comfyui_import_workflow_json
|
||||
|
||||
# Desde un archivo local en API format (passthrough)
|
||||
res = comfyui_import_workflow_json("/tmp/mi_workflow.json")
|
||||
# res == {"ok": True, "workflow": {...}, "format_detected": "api", "error": ""}
|
||||
|
||||
# Desde una URL (descarga + normaliza si viene como UI graph)
|
||||
res2 = comfyui_import_workflow_json("https://raw.githubusercontent.com/user/repo/main/wf.json")
|
||||
# res2["format_detected"] in ("api", "ui_graph")
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras lanzar un workflow ajeno (de OpenArt, ComfyWorkflows, raw GitHub o
|
||||
un .json local) por la API. Devuelve siempre API format, listo para
|
||||
`comfyui_validate_workflow` + `comfyui_submit_workflow`. Para workflows embebidos
|
||||
en un PNG usa `comfyui_import_workflow_png`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: HTTP GET si `source` es URL, lectura de disco si es path. Lectura/JSON
|
||||
invalido devuelven `{ok: False, error: ...}` (no lanza).
|
||||
- La conversion UI graph -> API es best-effort: las CONEXIONES entre nodos se
|
||||
reconstruyen siempre, pero el mapeo de los valores de widget (steps, cfg, texto)
|
||||
necesita `/object_info` del servidor. Si el servidor esta caido, los widgets del
|
||||
UI graph NO se mapean (quedan fuera) — valida el resultado antes de encolar.
|
||||
- El orden de widgets en object_info se asume = orden de widgets_values del UI
|
||||
graph; nodos custom muy raros pueden desalinearse.
|
||||
- API format se detecta porque todos los valores top-level son dicts con
|
||||
`class_type`; UI graph por la clave `nodes`. Otros JSON dan
|
||||
"formato no reconocido".
|
||||
- Los nodos virtuales del editor (`Note`, `MarkdownNote`, `PrimitiveNode`,
|
||||
`Reroute` y variantes `Reroute*`) NO aparecen en el API format resultante —
|
||||
igual que cuando ComfyUI exporta UI->API. Los `Reroute` se resuelven saltando
|
||||
el passthrough y reconectando el origen real al consumidor; una cadena de
|
||||
Reroutes rota (entrada sin link) o con ciclo deja el input sin conexion en
|
||||
lugar de apuntar a un nodo inexistente. Los `PrimitiveNode` se inyectan como
|
||||
valor literal de widget en el consumidor (su `widgets_values[0]`).
|
||||
- El filtrado es idempotente: un workflow ya en API format (sin nodos virtuales)
|
||||
pasa intacto; un UI graph sin virtuales conserva todas sus conexiones.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-24) — la conversion UI->API omite los nodos virtuales del
|
||||
editor (Note/MarkdownNote/PrimitiveNode/Reroute), resuelve los Reroute
|
||||
reconectando origen->destino e inyecta los PrimitiveNode como valor de widget.
|
||||
Antes esos nodos viajaban al API format y `comfyui_validate_workflow` los
|
||||
marcaba como `missing_nodes` (falsos positivos). Gap del report 0086.
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Importa un workflow ComfyUI desde una URL (http/https) o un path local.
|
||||
|
||||
Detecta el formato:
|
||||
- API format: dict {node_id: {class_type, inputs}} -> se devuelve tal cual.
|
||||
- UI graph: dict {nodes, links, ...} (lo que exporta "Save" en la UI) -> se
|
||||
normaliza a API format. La normalizacion de los valores de widget necesita el
|
||||
catalogo /object_info del servidor; si el servidor responde, los widgets se
|
||||
mapean por nombre; si no, solo se conservan las conexiones entre nodos.
|
||||
|
||||
Compone comfyui_object_info para el mapeo de widgets del UI graph.
|
||||
|
||||
Impura: red (HTTP GET si source es URL) + lectura de disco. Solo stdlib.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
from comfyui_object_info import comfyui_object_info # noqa: E402
|
||||
|
||||
|
||||
def comfyui_import_workflow_json(
|
||||
source: str,
|
||||
*,
|
||||
server: str = "127.0.0.1:8188",
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""Lee un workflow JSON y lo normaliza a API format.
|
||||
|
||||
Args:
|
||||
source: URL http(s) de un JSON de workflow, o ruta de un archivo local.
|
||||
server: host:port de ComfyUI usado solo para mapear los widgets cuando
|
||||
la fuente viene en formato UI graph. keyword-only.
|
||||
timeout: timeout HTTP en segundos. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, workflow, format_detected, error}. format_detected es "api",
|
||||
"ui_graph" o "". Si falla, ok=False y error explica el motivo.
|
||||
"""
|
||||
try:
|
||||
if source.startswith(("http://", "https://")):
|
||||
with urllib.request.urlopen(source, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
else:
|
||||
with open(source, "rb") as f:
|
||||
raw = f.read()
|
||||
data = json.loads(raw)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
return {"ok": False, "workflow": {}, "format_detected": "",
|
||||
"error": f"no se pudo leer {source!r}: {exc}"}
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"ok": False, "workflow": {}, "format_detected": "",
|
||||
"error": f"JSON invalido en {source!r}: {exc}"}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"ok": False, "workflow": {}, "format_detected": "",
|
||||
"error": "el JSON no es un objeto de workflow"}
|
||||
|
||||
# API format: todos los valores son dicts con class_type
|
||||
if data and all(isinstance(v, dict) and "class_type" in v for v in data.values()):
|
||||
return {"ok": True, "workflow": data, "format_detected": "api", "error": ""}
|
||||
|
||||
# UI graph: tiene la clave "nodes"
|
||||
if "nodes" in data:
|
||||
obj_info = None
|
||||
try:
|
||||
obj_info = comfyui_object_info(server=server, timeout=min(timeout, 5.0))
|
||||
except Exception:
|
||||
obj_info = None
|
||||
api = _ui_graph_to_api(data, obj_info)
|
||||
return {"ok": True, "workflow": api, "format_detected": "ui_graph", "error": ""}
|
||||
|
||||
return {"ok": False, "workflow": {}, "format_detected": "",
|
||||
"error": "formato de workflow no reconocido (ni API ni UI graph)"}
|
||||
|
||||
|
||||
# Node types virtuales del editor de ComfyUI: solo existen en el UI graph y se
|
||||
# descartan al pasar UI -> API (ComfyUI hace lo mismo). Note/MarkdownNote son
|
||||
# anotaciones; PrimitiveNode inyecta un valor de widget; Reroute es un passthrough
|
||||
# de una conexion (se resuelve reconectando origen real -> destino).
|
||||
_NOTE_TYPES = {"Note", "MarkdownNote"}
|
||||
|
||||
|
||||
def _is_reroute(ctype) -> bool:
|
||||
"""True si el node type es un Reroute (nativo 'Reroute' o variantes custom)."""
|
||||
return isinstance(ctype, str) and ctype.startswith("Reroute")
|
||||
|
||||
|
||||
def _is_virtual(ctype) -> bool:
|
||||
"""True si el node type es virtual del editor (no va al API format)."""
|
||||
return ctype in _NOTE_TYPES or ctype == "PrimitiveNode" or _is_reroute(ctype)
|
||||
|
||||
|
||||
def _resolve_source(src_node, src_slot, node_by_id, link_src, _depth=0):
|
||||
"""Resuelve el origen real de una conexion saltando los nodos Reroute.
|
||||
|
||||
Un Reroute en el UI graph es un passthrough: su salida solo reenvia lo que
|
||||
llega a su unica entrada. Para producir API format hay que reconectar el
|
||||
consumidor directamente al origen real (origen -> destino, sin el Reroute).
|
||||
Devuelve (node_id, slot) del nodo no-Reroute al que se conecta, o None si la
|
||||
cadena de Reroutes esta rota (entrada sin link) o forma un ciclo.
|
||||
"""
|
||||
if _depth > 64:
|
||||
return None # ciclo de Reroutes: aborta la resolucion.
|
||||
node = node_by_id.get(src_node)
|
||||
if node is None or not _is_reroute(node.get("type")):
|
||||
return (src_node, src_slot)
|
||||
link = None
|
||||
for inp in node.get("inputs", []) or []:
|
||||
if inp.get("link") is not None:
|
||||
link = inp["link"]
|
||||
break
|
||||
if link is None or link not in link_src:
|
||||
return None # Reroute sin entrada conectada: link muerto.
|
||||
nxt_node, nxt_slot = link_src[link]
|
||||
return _resolve_source(nxt_node, nxt_slot, node_by_id, link_src, _depth + 1)
|
||||
|
||||
|
||||
def _ui_graph_to_api(graph: dict, obj_info) -> dict:
|
||||
"""Convierte un UI graph de ComfyUI a API format (best-effort).
|
||||
|
||||
Omite los nodos virtuales del editor (Note, MarkdownNote, PrimitiveNode,
|
||||
Reroute) tal como hace ComfyUI al pasar de UI a API: las anotaciones se
|
||||
descartan, los Reroute se resuelven reconectando la conexion directa
|
||||
origen->destino, y los PrimitiveNode se inyectan como valor de widget en el
|
||||
consumidor.
|
||||
"""
|
||||
nodes = graph.get("nodes", []) or []
|
||||
links = graph.get("links", []) or []
|
||||
# link_id -> (src_node_id, src_slot)
|
||||
link_src = {}
|
||||
for lk in links:
|
||||
if isinstance(lk, list) and len(lk) >= 5:
|
||||
link_src[lk[0]] = (str(lk[1]), lk[2])
|
||||
# node_id (str) -> node dict, para TODOS los nodos (incluidos los virtuales),
|
||||
# necesario para resolver Reroutes e inyectar valores de PrimitiveNode.
|
||||
node_by_id = {str(n.get("id")): n for n in nodes if n.get("id") is not None}
|
||||
|
||||
api = {}
|
||||
for node in nodes:
|
||||
ctype = node.get("type")
|
||||
if ctype is None or _is_virtual(ctype):
|
||||
continue # los virtuales no existen en API format.
|
||||
nid = str(node.get("id"))
|
||||
inputs = {}
|
||||
connected = set()
|
||||
for inp in node.get("inputs", []) or []:
|
||||
name = inp.get("name")
|
||||
link = inp.get("link")
|
||||
if name is None or link is None or link not in link_src:
|
||||
continue
|
||||
src_node, src_slot = link_src[link]
|
||||
resolved = _resolve_source(src_node, src_slot, node_by_id, link_src)
|
||||
if resolved is None:
|
||||
continue # cadena de Reroutes rota: el input queda sin conexion.
|
||||
rnode, rslot = resolved
|
||||
src = node_by_id.get(rnode)
|
||||
if src is not None and src.get("type") == "PrimitiveNode":
|
||||
# PrimitiveNode: inyecta su valor constante como widget, no como link.
|
||||
wv = src.get("widgets_values")
|
||||
if isinstance(wv, list) and wv:
|
||||
inputs[name] = wv[0]
|
||||
connected.add(name)
|
||||
continue
|
||||
inputs[name] = [rnode, rslot]
|
||||
connected.add(name)
|
||||
widgets = node.get("widgets_values")
|
||||
if isinstance(widgets, dict):
|
||||
inputs.update(widgets)
|
||||
elif isinstance(widgets, list) and widgets:
|
||||
for name, val in zip(_widget_input_names(ctype, obj_info, connected), widgets):
|
||||
inputs[name] = val
|
||||
api[nid] = {"class_type": ctype, "inputs": inputs}
|
||||
return api
|
||||
|
||||
|
||||
def _widget_input_names(ctype, obj_info, connected) -> list:
|
||||
"""Nombres de inputs que son widgets (no conexiones), en orden, via object_info."""
|
||||
if not obj_info or ctype not in obj_info:
|
||||
return []
|
||||
spec = obj_info[ctype].get("input", {})
|
||||
names = []
|
||||
for section in ("required", "optional"):
|
||||
for name, decl in (spec.get(section) or {}).items():
|
||||
if name in connected:
|
||||
continue
|
||||
t = decl[0] if isinstance(decl, list) and decl else decl
|
||||
if isinstance(t, list):
|
||||
names.append(name) # combo/enum => widget
|
||||
elif t in ("INT", "FLOAT", "STRING", "BOOLEAN"):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = comfyui_import_workflow_json("/tmp/does_not_exist.json")
|
||||
print(res)
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: comfyui_import_workflow_png
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
|
||||
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
|
||||
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: png_path_or_url
|
||||
desc: "Ruta local de un PNG generado por ComfyUI, o URL http(s) de un PNG (ej. de ComfyUI_examples en GitHub)."
|
||||
- name: timeout
|
||||
desc: "Timeout HTTP en segundos (solo si es URL). keyword-only."
|
||||
output: "dict {ok, prompt, workflow, format_detected, error}. prompt = API format (dict) si existe el chunk 'prompt'; workflow = UI graph (dict) si existe el chunk 'workflow'; format_detected = chunks hallados ('prompt', 'workflow' o 'prompt+workflow'). Si no hay metadata, ok=False."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_import_workflow_png.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_import_workflow_png import comfyui_import_workflow_png
|
||||
|
||||
# Desde un PNG generado localmente
|
||||
res = comfyui_import_workflow_png(os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"))
|
||||
# res["ok"] == True
|
||||
# res["format_detected"] # "prompt" (generado por API) o "prompt+workflow" (desde la UI)
|
||||
# res["prompt"]["3"]["class_type"] == "KSampler"
|
||||
|
||||
# Desde una URL (un PNG de ComfyUI_examples trae el workflow embebido)
|
||||
res2 = comfyui_import_workflow_png("https://raw.githubusercontent.com/comfyanonymous/ComfyUI_examples/master/...png")
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando alguien te pase una imagen PNG de ComfyUI (de los `ComfyUI_examples`, de la
|
||||
comunidad, o tuya) y quieras recuperar el workflow exacto que la genero para
|
||||
relanzarlo o editarlo. El `prompt` (API format) va directo a
|
||||
`comfyui_validate_workflow` + `comfyui_submit_workflow`; el `workflow` (UI graph)
|
||||
puede cargarse en la UI con `comfyui_load_workflow_ui`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: HTTP GET si es URL, lectura de disco si es path. Errores devuelven
|
||||
`{ok: False, error: ...}` (no lanza).
|
||||
- Solo PNG: lee chunks tEXt/zTXt/iTXt. Los JPG/WebP NO llevan estos chunks (usa
|
||||
otra via). Un PNG sin metadata de ComfyUI da `ok=False`.
|
||||
- Los PNG generados por la API REST solo traen el chunk `prompt`; los generados
|
||||
desde la UI traen ademas `workflow`. Por eso `format_detected` puede ser solo
|
||||
"prompt".
|
||||
- El `prompt` recuperado es API format, no el UI graph: para reabrirlo visualmente
|
||||
usa el `workflow` (si existe) o reconstruye el grafo desde el API format en la UI.
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI.
|
||||
|
||||
ComfyUI guarda en los PNG generados dos chunks de texto:
|
||||
- "prompt": el workflow en API format (lo que se envio a POST /prompt).
|
||||
- "workflow": el grafo de la UI (UI graph), presente si se genero desde la UI.
|
||||
|
||||
Lee chunks tEXt, zTXt e iTXt con stdlib (struct, zlib). Impura: red opcional (si
|
||||
source es URL) + lectura de disco.
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zlib
|
||||
|
||||
|
||||
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
|
||||
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
|
||||
|
||||
Args:
|
||||
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
|
||||
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, prompt, workflow, format_detected, error}:
|
||||
- prompt: API format (dict) si el chunk "prompt" existe, si no {}.
|
||||
- workflow: UI graph (dict) si el chunk "workflow" existe, si no {}.
|
||||
- format_detected: chunks hallados unidos por "+" ("prompt",
|
||||
"workflow" o "prompt+workflow").
|
||||
Si el PNG no trae metadata de workflow, ok=False.
|
||||
"""
|
||||
try:
|
||||
if png_path_or_url.startswith(("http://", "https://")):
|
||||
with urllib.request.urlopen(png_path_or_url, timeout=timeout) as resp:
|
||||
data = resp.read()
|
||||
else:
|
||||
with open(png_path_or_url, "rb") as f:
|
||||
data = f.read()
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
return {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "",
|
||||
"error": f"no se pudo leer {png_path_or_url!r}: {exc}"}
|
||||
|
||||
try:
|
||||
chunks = _png_text_chunks(data)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "",
|
||||
"error": str(exc)}
|
||||
|
||||
out = {"ok": False, "prompt": {}, "workflow": {}, "format_detected": "", "error": ""}
|
||||
found = []
|
||||
if "prompt" in chunks:
|
||||
try:
|
||||
out["prompt"] = json.loads(chunks["prompt"])
|
||||
found.append("prompt")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if "workflow" in chunks:
|
||||
try:
|
||||
out["workflow"] = json.loads(chunks["workflow"])
|
||||
found.append("workflow")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
out["format_detected"] = "+".join(found)
|
||||
if found:
|
||||
out["ok"] = True
|
||||
else:
|
||||
out["error"] = "el PNG no contiene metadata de workflow ComfyUI (chunks prompt/workflow)"
|
||||
return out
|
||||
|
||||
|
||||
def _png_text_chunks(data: bytes) -> dict:
|
||||
"""Lee los chunks de texto (tEXt/zTXt/iTXt) de un PNG -> {keyword: texto}."""
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise ValueError("no es un PNG valido (firma incorrecta)")
|
||||
out = {}
|
||||
off = 8
|
||||
n = len(data)
|
||||
while off + 8 <= n:
|
||||
length = struct.unpack(">I", data[off:off + 4])[0]
|
||||
ctype = data[off + 4:off + 8]
|
||||
body = data[off + 8:off + 8 + length]
|
||||
off += 12 + length # 4 len + 4 type + body + 4 crc
|
||||
if ctype == b"tEXt":
|
||||
kw, _, txt = body.partition(b"\x00")
|
||||
out[kw.decode("latin1")] = txt.decode("latin1")
|
||||
elif ctype == b"zTXt":
|
||||
kw, _, rest = body.partition(b"\x00")
|
||||
if rest:
|
||||
comp_data = rest[1:] # rest[0] = metodo de compresion
|
||||
try:
|
||||
out[kw.decode("latin1")] = zlib.decompress(comp_data).decode("latin1")
|
||||
except zlib.error:
|
||||
pass
|
||||
elif ctype == b"iTXt":
|
||||
kw, _, rest = body.partition(b"\x00")
|
||||
if len(rest) >= 2:
|
||||
comp_flag = rest[0]
|
||||
parts = rest[2:].split(b"\x00", 2) # lang\x00 translated\x00 text
|
||||
if len(parts) == 3:
|
||||
text_bytes = parts[2]
|
||||
if comp_flag == 1:
|
||||
try:
|
||||
text_bytes = zlib.decompress(text_bytes)
|
||||
except zlib.error:
|
||||
text_bytes = b""
|
||||
out[kw.decode("latin1")] = text_bytes.decode("utf-8", "replace")
|
||||
elif ctype == b"IEND":
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json as _json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png"
|
||||
res = comfyui_import_workflow_png(path)
|
||||
print(_json.dumps({k: v for k, v in res.items() if k != "prompt"}, indent=2))
|
||||
print("nodos en prompt:", len(res["prompt"]))
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: comfyui_inject_lora
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def comfyui_inject_lora(workflow: dict, lora_name: str, *, strength_model: float = 1.0, strength_clip: float = 1.0, model_node: str | None = None, clip_node: str | None = None) -> dict"
|
||||
description: "Inserta un nodo LoraLoader en un workflow ComfyUI ya construido (API format), reconectando las salidas model/clip de la fuente actual (CheckpointLoaderSimple o LoraLoader previo) hacia el LoRA y repuntando a los consumidores (KSampler, CLIPTextEncode). Llamar varias veces encadena LoRAs. Pura: no muta el dict de entrada (copia profunda)."
|
||||
tags: [comfyui, ml, lora, stable-diffusion, workflow]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: workflow
|
||||
desc: "dict en API format (ej. salida de comfyui_build_txt2img_workflow). No se muta; se devuelve una copia."
|
||||
- name: lora_name
|
||||
desc: "Nombre del archivo .safetensors del LoRA en models/loras/."
|
||||
- name: strength_model
|
||||
desc: "Fuerza del LoRA sobre el modelo (UNet). keyword-only."
|
||||
- name: strength_clip
|
||||
desc: "Fuerza del LoRA sobre el CLIP. keyword-only."
|
||||
- name: model_node
|
||||
desc: "node_id cuya salida MODEL (slot 0) alimentara el LoRA. Si None, se detecta la fuente que hoy alimenta KSampler.model (con el CheckpointLoaderSimple como fallback). keyword-only."
|
||||
- name: clip_node
|
||||
desc: "node_id cuya salida CLIP (slot 1) alimentara el LoRA. Si None, se detecta la fuente que hoy alimenta los CLIPTextEncode.clip. keyword-only."
|
||||
output: "copia del workflow con un nodo LoraLoader insertado (node_id = max id numerico + 1) y reconectado entre la fuente model/clip y sus consumidores."
|
||||
tested: true
|
||||
tests: ["no muta el dict de entrada (pureza)", "inserta LoraLoader con strength correcto", "reconecta KSampler.model al LoRA", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||
test_file_path: "python/functions/ml/tests/test_comfyui_inject_lora.py"
|
||||
file_path: "python/functions/ml/comfyui_inject_lora.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
from ml.comfyui_inject_lora import comfyui_inject_lora
|
||||
|
||||
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat, detailed")
|
||||
wf = comfyui_inject_lora(base, "add_detail.safetensors", strength_model=0.8)
|
||||
# El LoraLoader nuevo recibe model/clip del checkpoint ["4",0]/["4",1]
|
||||
# y ahora KSampler.model == [lora_id, 0], CLIPTextEncode.clip == [lora_id, 1]
|
||||
|
||||
# Encadenar un segundo LoRA: el detector ve que ya pasa por el primero
|
||||
wf = comfyui_inject_lora(wf, "anime_style.safetensors", strength_model=0.6)
|
||||
# Cadena: checkpoint -> lora1 -> lora2 -> KSampler / CLIPTextEncode
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un workflow txt2img/img2img construido y quieras aplicarle uno o
|
||||
varios LoRAs sin reescribir el grafo. Llama una vez por LoRA: cada llamada inserta
|
||||
el LoraLoader justo antes de los consumidores actuales, asi que encadenar es
|
||||
idempotente respecto al orden de llamada. Para apilar muchos LoRAs, encadena.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Pura: no muta el `workflow` de entrada (trabaja sobre una copia profunda) y NO
|
||||
valida que `lora_name` exista en el servidor. Valida con `comfyui_validate_workflow`.
|
||||
- Asume la convencion de slots de ComfyUI: MODEL=output 0, CLIP=output 1, tanto
|
||||
en CheckpointLoaderSimple como en LoraLoader. Workflows con loaders no estandar
|
||||
pueden necesitar `model_node`/`clip_node` explicitos.
|
||||
- Detecta la fuente actual por el KSampler.model y el primer CLIPTextEncode.clip.
|
||||
Si el workflow no tiene un nodo cuyo class_type acabe en "KSampler", pasa
|
||||
`model_node` explicito o lanza ValueError.
|
||||
- El nuevo node_id es `max(ids numericos) + 1`. Si tu workflow usa ids no
|
||||
numericos, el contador cae a `len(workflow) + 1`.
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Inserta un nodo LoraLoader en un workflow ComfyUI ya construido (API format).
|
||||
|
||||
Reconecta las salidas model/clip de la fuente actual (el CheckpointLoaderSimple
|
||||
o un LoraLoader previo) hacia el nuevo LoraLoader, y repunta a los consumidores
|
||||
(KSampler, CLIPTextEncode) para que pasen por el LoRA. Llamar varias veces sobre
|
||||
el mismo workflow encadena LoRAs.
|
||||
|
||||
Convencion de slots ComfyUI: tanto CheckpointLoaderSimple como LoraLoader
|
||||
exponen MODEL en el output 0 y CLIP en el output 1.
|
||||
|
||||
Funcion pura: no muta el dict de entrada (trabaja sobre una copia profunda).
|
||||
"""
|
||||
import copy
|
||||
|
||||
|
||||
def comfyui_inject_lora(
|
||||
workflow: dict,
|
||||
lora_name: str,
|
||||
*,
|
||||
strength_model: float = 1.0,
|
||||
strength_clip: float = 1.0,
|
||||
model_node: str | None = None,
|
||||
clip_node: str | None = None,
|
||||
) -> dict:
|
||||
"""Devuelve una copia del workflow con un LoraLoader insertado y reconectado.
|
||||
|
||||
Args:
|
||||
workflow: dict en API format (ej. salida de
|
||||
comfyui_build_txt2img_workflow). No se muta.
|
||||
lora_name: nombre del archivo .safetensors del LoRA en models/loras/.
|
||||
strength_model: fuerza del LoRA sobre el modelo (UNet). keyword-only.
|
||||
strength_clip: fuerza del LoRA sobre el CLIP. keyword-only.
|
||||
model_node: node_id cuya salida MODEL (slot 0) alimentara el LoRA. Si
|
||||
None, se detecta la fuente que hoy alimenta el KSampler.model (con el
|
||||
CheckpointLoaderSimple como fallback). keyword-only.
|
||||
clip_node: node_id cuya salida CLIP (slot 1) alimentara el LoRA. Si None,
|
||||
se detecta la fuente que hoy alimenta los CLIPTextEncode.clip.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
copia del workflow con el LoraLoader insertado. El nuevo node_id es el
|
||||
maximo id numerico existente + 1.
|
||||
|
||||
Raises:
|
||||
ValueError: si no se puede determinar la fuente model/clip y no se pasan
|
||||
model_node/clip_node explicitos.
|
||||
"""
|
||||
wf = copy.deepcopy(workflow)
|
||||
|
||||
def _is_link(v) -> bool:
|
||||
return (
|
||||
isinstance(v, list)
|
||||
and len(v) == 2
|
||||
and isinstance(v[0], str)
|
||||
and isinstance(v[1], int)
|
||||
)
|
||||
|
||||
def _find_class(prefix):
|
||||
for nid, node in wf.items():
|
||||
if str(node.get("class_type", "")).startswith(prefix):
|
||||
return nid
|
||||
return None
|
||||
|
||||
ckpt = _find_class("CheckpointLoader")
|
||||
|
||||
# fuente actual de model/clip: la que alimenta KSampler.model y CLIPTextEncode.clip
|
||||
model_src = None
|
||||
clip_src = None
|
||||
for node in wf.values():
|
||||
ins = node.get("inputs", {})
|
||||
if str(node.get("class_type", "")).endswith("KSampler") and _is_link(ins.get("model")):
|
||||
model_src = list(ins["model"])
|
||||
if node.get("class_type") == "CLIPTextEncode" and clip_src is None and _is_link(ins.get("clip")):
|
||||
clip_src = list(ins["clip"])
|
||||
|
||||
if model_node is not None:
|
||||
model_src = [model_node, 0]
|
||||
elif model_src is None and ckpt is not None:
|
||||
model_src = [ckpt, 0]
|
||||
|
||||
if clip_node is not None:
|
||||
clip_src = [clip_node, 1]
|
||||
elif clip_src is None and ckpt is not None:
|
||||
clip_src = [ckpt, 1]
|
||||
|
||||
if model_src is None or clip_src is None:
|
||||
raise ValueError(
|
||||
"comfyui_inject_lora: no se pudo determinar la fuente model/clip; "
|
||||
"pasa model_node y clip_node explicitos."
|
||||
)
|
||||
|
||||
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||
new_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||
|
||||
wf[new_id] = {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": lora_name,
|
||||
"strength_model": strength_model,
|
||||
"strength_clip": strength_clip,
|
||||
"model": list(model_src),
|
||||
"clip": list(clip_src),
|
||||
},
|
||||
}
|
||||
|
||||
# repuntar consumidores de model_src/clip_src hacia el LoraLoader (no el propio LoRA)
|
||||
for nid, node in wf.items():
|
||||
if nid == new_id:
|
||||
continue
|
||||
ins = node.get("inputs", {})
|
||||
for k, v in list(ins.items()):
|
||||
if _is_link(v) and list(v) == list(model_src):
|
||||
ins[k] = [new_id, 0]
|
||||
elif _is_link(v) and list(v) == list(clip_src):
|
||||
ins[k] = [new_id, 1]
|
||||
|
||||
return wf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||
|
||||
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat")
|
||||
wf = comfyui_inject_lora(base, "add_detail.safetensors", strength_model=0.8)
|
||||
print(json.dumps(wf, indent=2))
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: comfyui_install_3d_model
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_install_3d_model(variant: str = \"mini\", *, hf_token: str | None = None, comfyui_dir: str = \"~/ComfyUI\") -> dict"
|
||||
description: "Instala un checkpoint Hunyuan3D-2 (mini/standard/mv) en la carpeta checkpoints/ de ComfyUI para los nodos nativos imagen->3D (ImageOnlyCheckpointLoader). Cascada: si el destino ya existe reutiliza; si esta en la cache de HuggingFace copia desde ahi (sin red); si no, descarga con huggingface_hub (token de pass si gated). Resuelve la ruta real de checkpoints via extra_model_paths.yaml. Impura: YAML + disco + posible red + subprocess pass."
|
||||
tags: [comfyui, ml, img-to-3d, hunyuan3d, model, install]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: variant
|
||||
desc: "'mini' (≈5 GB VRAM, default), 'standard' (dit-v2-0, ≈6 GB) o 'mv' (multiview). Determina el repo de HF y el nombre destino del .safetensors."
|
||||
- name: hf_token
|
||||
desc: "Token de HuggingFace si la variante fuera gated. Si None y hace falta descargar, se intenta leer de 'pass show API_TOKEN_huggingFace'. keyword-only."
|
||||
- name: comfyui_dir
|
||||
desc: "Raiz de la instalacion de ComfyUI (se expande ~). La carpeta real de checkpoints se resuelve via extra_model_paths.yaml. keyword-only."
|
||||
output: "dict {ok, path, bytes, reused_cache, error}. path = ruta del checkpoint en checkpoints/; reused_cache=True si ya estaba instalado o se copio de la cache de HF (sin descarga de red). Si falla, ok=False y error explica."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_install_3d_model.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_install_3d_model import comfyui_install_3d_model
|
||||
|
||||
res = comfyui_install_3d_model("mini")
|
||||
# Si ya esta (cache o instalado): reused_cache=True, sin re-bajar 3.8 GB.
|
||||
# res == {"ok": True, "path": "/mnt/2tb/comfyui_models/checkpoints/hunyuan3d-dit-v2-mini.safetensors",
|
||||
# "bytes": 3819958234, "reused_cache": True, "error": ""}
|
||||
```
|
||||
|
||||
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de reconstruir mallas 3D con los nodos nativos de Hunyuan3D-2: asegura que
|
||||
el checkpoint que pide `ImageOnlyCheckpointLoader` esta en `checkpoints/`. Llamala
|
||||
una vez por PC/variante; en sucesivas devuelve `reused_cache=True` al instante. El
|
||||
pipeline `comfyui_image_to_3d_oneshot` NO la llama (asume el modelo ya instalado);
|
||||
ejecutala tu antes la primera vez.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee YAML, escribe en disco (copia de GBs cuando toca), y puede hacer red
|
||||
+ subprocess `pass`. La copia desde la cache de HF de un .safetensors de ~3.8 GB
|
||||
tarda unos segundos; el caso `reused_cache` ya-instalado es instantaneo.
|
||||
- Resuelve la carpeta de checkpoints real via `extra_model_paths.yaml` (en este
|
||||
equipo `/mnt/2tb/comfyui_models/checkpoints/`, seccion `is_default`). Si el YAML
|
||||
falta cae a `<comfyui_dir>/models/checkpoints`.
|
||||
- La descarga (rama 3) necesita `huggingface_hub` en el venv. Si no esta instalado
|
||||
y el modelo no esta en la cache, devuelve `ok=False` con instrucciones (instalar
|
||||
huggingface_hub o usar `comfyui_download_model` con la URL de resolve de HF).
|
||||
- Hunyuan3D-2 mini NO es gated (no requiere token). `standard`/`mv` se asumen
|
||||
publicos tambien; si alguno fuera gated, pasa `hf_token` o ten el token en `pass`.
|
||||
- Tras instalar, ComfyUI re-escanea `checkpoints/` dinamicamente (no hace falta
|
||||
reiniciar el server para checkpoints; solo los custom nodes nuevos exigen restart).
|
||||
- No valida el contenido del .safetensors mas alla de un tamano minimo; confia en
|
||||
la integridad de la cache de HF o de la descarga de huggingface_hub.
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Instala un checkpoint Hunyuan3D-2 en la carpeta checkpoints/ de ComfyUI.
|
||||
|
||||
ComfyUI 0.26.0 reconstruye mallas 3D con los nodos nativos de Hunyuan3D-2, que
|
||||
cargan un checkpoint self-contained (DiT de forma + VAE 3D + encoder de imagen en
|
||||
un solo .safetensors) via ImageOnlyCheckpointLoader. Esta funcion resuelve el repo
|
||||
de HuggingFace de la variante pedida, REUTILIZA la cache de HF si ya esta bajado
|
||||
(sin re-descargar), y copia el .safetensors a la carpeta checkpoints/ (la ruta real
|
||||
que declara extra_model_paths.yaml) con el nombre que espera el loader nativo.
|
||||
|
||||
Cascada: (1) si el destino ya existe -> reutiliza; (2) si esta en la cache de HF
|
||||
-> copia desde la cache; (3) si no -> descarga con huggingface_hub (token de
|
||||
`pass` si la variante fuera gated).
|
||||
|
||||
Impura: lectura de YAML, escritura en disco, posible red (HTTP) y subprocess (pass).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
# variant -> (repo_id HF, ruta del archivo dentro del repo, nombre destino en checkpoints/)
|
||||
_VARIANTS = {
|
||||
"mini": (
|
||||
"tencent/Hunyuan3D-2mini",
|
||||
"hunyuan3d-dit-v2-mini/model.fp16.safetensors",
|
||||
"hunyuan3d-dit-v2-mini.safetensors",
|
||||
),
|
||||
"standard": (
|
||||
"tencent/Hunyuan3D-2",
|
||||
"hunyuan3d-dit-v2-0/model.fp16.safetensors",
|
||||
"hunyuan3d-dit-v2-0.safetensors",
|
||||
),
|
||||
"mv": (
|
||||
"tencent/Hunyuan3D-2mv",
|
||||
"hunyuan3d-dit-v2-mv/model.fp16.safetensors",
|
||||
"hunyuan3d-dit-v2-mv.safetensors",
|
||||
),
|
||||
}
|
||||
|
||||
_MIN_BYTES = 1_000_000 # un .safetensors real pesa GBs; descarta restos/HTML.
|
||||
|
||||
|
||||
def _checkpoints_dir(comfyui_dir: str) -> str:
|
||||
"""Resuelve el directorio real de checkpoints de ComfyUI.
|
||||
|
||||
Lee extra_model_paths.yaml (prefiere la seccion con is_default) para devolver
|
||||
`<base_path>/<checkpoints_subdir>`. Si el YAML no existe o no se puede parsear,
|
||||
cae a la ruta nativa `<comfyui_dir>/models/checkpoints`.
|
||||
"""
|
||||
base = os.path.expanduser(comfyui_dir)
|
||||
native = os.path.join(base, "models", "checkpoints")
|
||||
yml = os.path.join(base, "extra_model_paths.yaml")
|
||||
if not os.path.isfile(yml):
|
||||
return native
|
||||
try:
|
||||
import yaml
|
||||
with open(yml, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
except Exception: # noqa: BLE001 — YAML/PyYAML no disponible: usar nativa.
|
||||
return native
|
||||
if not isinstance(data, dict):
|
||||
return native
|
||||
fallback = None
|
||||
for section in data.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
sub = section.get("checkpoints")
|
||||
if not sub:
|
||||
continue
|
||||
bp = os.path.expanduser(str(section.get("base_path", "")))
|
||||
first_line = str(sub).splitlines()[0].strip()
|
||||
resolved = os.path.join(bp, first_line)
|
||||
if section.get("is_default"):
|
||||
return resolved
|
||||
if fallback is None:
|
||||
fallback = resolved
|
||||
return fallback or native
|
||||
|
||||
|
||||
def _find_in_hf_cache(repo_id: str, repo_filename: str) -> str | None:
|
||||
"""Busca el archivo en la cache local de HuggingFace, sin red.
|
||||
|
||||
Layout: ~/.cache/huggingface/hub/models--<org>--<name>/snapshots/<hash>/...
|
||||
Resuelve el symlink al blob real y verifica un tamano minimo. Devuelve la ruta
|
||||
real o None.
|
||||
"""
|
||||
org_name = repo_id.replace("/", "--")
|
||||
hub = os.path.expanduser("~/.cache/huggingface/hub")
|
||||
cache_root = os.path.join(hub, f"models--{org_name}", "snapshots")
|
||||
if not os.path.isdir(cache_root):
|
||||
return None
|
||||
target = os.path.basename(repo_filename)
|
||||
for snap in os.listdir(cache_root):
|
||||
snap_dir = os.path.join(cache_root, snap)
|
||||
if not os.path.isdir(snap_dir):
|
||||
continue
|
||||
for root, _dirs, files in os.walk(snap_dir):
|
||||
if target in files:
|
||||
real = os.path.realpath(os.path.join(root, target))
|
||||
if os.path.isfile(real) and os.path.getsize(real) >= _MIN_BYTES:
|
||||
return real
|
||||
return None
|
||||
|
||||
|
||||
def _pass_hf_token() -> str | None:
|
||||
"""Lee el token de HuggingFace de `pass API_TOKEN_huggingFace`, o None."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["pass", "show", "API_TOKEN_huggingFace"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if out.returncode == 0:
|
||||
tok = out.stdout.splitlines()[0].strip() if out.stdout.strip() else ""
|
||||
return tok or None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def comfyui_install_3d_model(
|
||||
variant: str = "mini",
|
||||
*,
|
||||
hf_token: str | None = None,
|
||||
comfyui_dir: str = "~/ComfyUI",
|
||||
) -> dict:
|
||||
"""Instala el checkpoint Hunyuan3D-2 de la variante pedida en checkpoints/.
|
||||
|
||||
Args:
|
||||
variant: "mini" (≈5 GB VRAM, default), "standard" (dit-v2-0, ≈6 GB) o
|
||||
"mv" (multiview). Determina el repo de HF y el nombre destino.
|
||||
hf_token: token de HuggingFace si la variante fuera gated. Si None y hace
|
||||
falta descargar, se intenta leer de `pass show API_TOKEN_huggingFace`.
|
||||
keyword-only.
|
||||
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~). La carpeta
|
||||
real de checkpoints se resuelve via extra_model_paths.yaml. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, path, bytes, reused_cache, error}. path = ruta del checkpoint en
|
||||
checkpoints/; reused_cache=True si ya estaba instalado o se copio de la
|
||||
cache de HF (sin descarga de red). Si falla, ok=False y error explica.
|
||||
"""
|
||||
if variant not in _VARIANTS:
|
||||
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
|
||||
"error": f"variant {variant!r} no valida; usa {sorted(_VARIANTS)}"}
|
||||
repo_id, repo_filename, dest_name = _VARIANTS[variant]
|
||||
ckpt_dir = _checkpoints_dir(comfyui_dir)
|
||||
dest = os.path.join(ckpt_dir, dest_name)
|
||||
|
||||
# 1. Ya instalado.
|
||||
if os.path.isfile(dest) and os.path.getsize(dest) >= _MIN_BYTES:
|
||||
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
|
||||
"reused_cache": True, "error": ""}
|
||||
|
||||
# 2. En la cache de HF -> copiar (sin red).
|
||||
cached = _find_in_hf_cache(repo_id, repo_filename)
|
||||
if cached:
|
||||
try:
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
shutil.copy2(cached, dest)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
|
||||
"error": f"no se pudo copiar de la cache HF a {dest}: {exc}"}
|
||||
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
|
||||
"reused_cache": True, "error": ""}
|
||||
|
||||
# 3. Descargar con huggingface_hub (lazy; usa su propia cache).
|
||||
token = hf_token or _pass_hf_token()
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
except ImportError:
|
||||
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
|
||||
"error": ("no esta en la cache de HF y huggingface_hub no esta "
|
||||
"instalado en este venv. Instala huggingface_hub o baja "
|
||||
f"el archivo {repo_filename!r} de {repo_id!r} a mano (o con "
|
||||
"comfyui_download_model usando la URL de resolve de HF).")}
|
||||
try:
|
||||
local = hf_hub_download(repo_id=repo_id, filename=repo_filename, token=token)
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
shutil.copy2(local, dest)
|
||||
except Exception as exc: # noqa: BLE001 — red/auth/gated/escritura.
|
||||
return {"ok": False, "path": "", "bytes": 0, "reused_cache": False,
|
||||
"error": f"fallo descargando {repo_filename} de {repo_id}: {exc}"}
|
||||
return {"ok": True, "path": dest, "bytes": os.path.getsize(dest),
|
||||
"reused_cache": False, "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
print(json.dumps(comfyui_install_3d_model("mini"), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: comfyui_install_custom_node
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_install_custom_node(repo_url: str, *, comfyui_dir: str = \"~/ComfyUI\", pip_install: bool = True, restart: bool = False) -> dict"
|
||||
description: "Instala un custom node de ComfyUI: git clone del repo en custom_nodes/<name> + (si trae requirements.txt) pip install de sus deps en el venv de ComfyUI. El venv suele crearse con uv y no trae pip, asi que el instalador se autodetecta (python -m pip -> uv pip -> pip). NO reinicia el servidor por defecto (restart=False): el nodo se carga al siguiente arranque. Impura: subprocess git/pip/uv + escritura en disco. Solo stdlib."
|
||||
tags: [comfyui, ml, custom-nodes, install, git, pip, stable-diffusion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "shutil", "subprocess"]
|
||||
params:
|
||||
- name: repo_url
|
||||
desc: "URL del repositorio git del custom node (ej. 'https://github.com/rgthree/rgthree-comfy')."
|
||||
- name: comfyui_dir
|
||||
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'. keyword-only."
|
||||
- name: pip_install
|
||||
desc: "Si True y el repo trae requirements.txt, instala sus dependencias en el venv de ComfyUI. keyword-only."
|
||||
- name: restart
|
||||
desc: "NO soportado de forma segura (default False). El nodo se carga al reiniciar el servidor; hazlo tu cuando no haya generaciones en curso. True solo se anota en error, NO reinicia (evita cortar trabajo del servidor). keyword-only."
|
||||
output: "dict {ok, path, pip_done, error}. ok=True si el nodo quedo clonado en disco (o ya estaba). pip_done=True si se instalaron las dependencias. error describe el fallo de git/pip o las advertencias (ya existia, sin requirements, restart ignorado)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_install_custom_node.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_install_custom_node import comfyui_install_custom_node
|
||||
|
||||
out = comfyui_install_custom_node(
|
||||
"https://github.com/rgthree/rgthree-comfy",
|
||||
restart=False, # no reinicia el server; el nodo se carga al proximo arranque
|
||||
)
|
||||
print(out["ok"], out["path"], "pip_done=", out["pip_done"])
|
||||
# {"ok": True, "path": ".../custom_nodes/rgthree-comfy", "pip_done": True, "error": ""}
|
||||
```
|
||||
|
||||
El `./fn run` directo no aplica (firma con `*` keyword-only); usa el import o un
|
||||
heredoc.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un workflow ajeno usa un nodo custom que no tienes
|
||||
(`comfyui_resolve_workflow_deps` te dice cual falta) o quieras anadir un pack de
|
||||
nodos conocido. Tras instalar, reinicia ComfyUI manualmente (cuando no haya
|
||||
generaciones en curso) para que el nodo aparezca.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: ejecuta `git clone` y, si hay requirements.txt, `pip`/`uv pip` en el
|
||||
venv de ComfyUI; escribe en `~/ComfyUI/custom_nodes/`.
|
||||
- **NO reinicia el servidor**. `restart=True` se ignora (solo se anota en `error`):
|
||||
un restart en caliente corta cualquier generacion en curso. Reinicia tu cuando
|
||||
el server este libre. El nodo NO se carga hasta ese reinicio.
|
||||
- El venv de ComfyUI creado con uv no trae `pip`: la funcion detecta el instalador
|
||||
(`python -m pip` -> `uv pip --python <venv>` -> binario `pip`). Si no hay ninguno,
|
||||
`pip_done=False` y lo anota en `error` (el clone sigue siendo valido).
|
||||
- Idempotente con el clone: si el dir ya existe NO re-clona (lo anota en `error`),
|
||||
pero SI reintenta el pip install si `pip_install=True`.
|
||||
- `ok=True` significa "clonado en disco", no "cargado en el server". Un clone OK
|
||||
con pip fallido devuelve `ok=True, pip_done=False` + el error de pip.
|
||||
- Un repo_url invalido (404) devuelve `ok=False` con el stderr de git.
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Instala un custom node de ComfyUI: git clone + pip install de sus deps.
|
||||
|
||||
Clona el repo en `<comfyui_dir>/custom_nodes/<name>` y, si trae
|
||||
`requirements.txt`, instala sus dependencias en el venv de ComfyUI
|
||||
(`<comfyui_dir>/.venv`). El venv de ComfyUI suele crearse con uv y no trae pip;
|
||||
por eso el instalador se autodetecta en orden: `python -m pip`, luego
|
||||
`uv pip --python <venv>`, luego el binario `pip` del venv. NO reinicia el
|
||||
servidor por defecto (restart=False): el nodo no se carga hasta el siguiente
|
||||
arranque de ComfyUI, asi que reiniciar es una decision explicita del caller (un
|
||||
restart en caliente corta cualquier generacion en curso).
|
||||
|
||||
Impura: ejecuta subprocess (git, pip/uv) y escribe en disco. Solo stdlib.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_GIT_TIMEOUT = 300.0
|
||||
_PIP_TIMEOUT = 600.0
|
||||
|
||||
|
||||
def _pip_install_cmd(base: str, req: str):
|
||||
"""Comando para instalar requirements en el venv de ComfyUI, o None.
|
||||
|
||||
Prueba en orden: `python -m pip` (si el venv tiene pip), `uv pip` apuntando
|
||||
al python del venv (venvs uv sin pip), y por ultimo el binario `pip` del
|
||||
venv. Devuelve la lista de args lista para subprocess o None si no hay
|
||||
instalador disponible.
|
||||
"""
|
||||
venv_py = os.path.join(base, ".venv", "bin", "python")
|
||||
if os.path.isfile(venv_py):
|
||||
probe = subprocess.run(
|
||||
[venv_py, "-m", "pip", "--version"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return [venv_py, "-m", "pip", "install", "-r", req]
|
||||
if shutil.which("uv"):
|
||||
return ["uv", "pip", "install", "-r", req, "--python", venv_py]
|
||||
for cand in ("pip", "pip3"):
|
||||
pip_bin = os.path.join(base, ".venv", "bin", cand)
|
||||
if os.path.isfile(pip_bin):
|
||||
return [pip_bin, "install", "-r", req]
|
||||
return None
|
||||
|
||||
|
||||
def comfyui_install_custom_node(
|
||||
repo_url: str,
|
||||
*,
|
||||
comfyui_dir: str = "~/ComfyUI",
|
||||
pip_install: bool = True,
|
||||
restart: bool = False,
|
||||
) -> dict:
|
||||
"""Clona un custom node y (opcional) instala sus requirements.
|
||||
|
||||
Args:
|
||||
repo_url: URL del repositorio git del custom node
|
||||
(ej. "https://github.com/rgthree/rgthree-comfy").
|
||||
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
|
||||
keyword-only.
|
||||
pip_install: si True y el repo trae requirements.txt, instala sus
|
||||
dependencias en el venv de ComfyUI. keyword-only.
|
||||
restart: NO soportado de forma segura desde aqui (por defecto False).
|
||||
El nodo se carga al reiniciar el servidor; hazlo tu cuando no haya
|
||||
generaciones en curso. Si se pasa True se anota en el error pero NO
|
||||
se reinicia (evita cortar trabajo del servidor). keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, path, pip_done, error}. ok=True si el nodo quedo clonado en
|
||||
disco (o ya estaba). pip_done=True si se instalaron las dependencias.
|
||||
error describe el fallo de git/pip o la advertencia de restart.
|
||||
"""
|
||||
base = os.path.expanduser(comfyui_dir)
|
||||
custom_dir = os.path.join(base, "custom_nodes")
|
||||
name = os.path.basename(repo_url.rstrip("/"))
|
||||
if name.endswith(".git"):
|
||||
name = name[:-4]
|
||||
if not name:
|
||||
return {"ok": False, "path": "", "pip_done": False,
|
||||
"error": f"repo_url invalido: {repo_url!r}"}
|
||||
|
||||
dest = os.path.join(custom_dir, name)
|
||||
already = os.path.isdir(dest)
|
||||
if not already:
|
||||
os.makedirs(custom_dir, exist_ok=True)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "clone", "--depth", "1", repo_url, dest],
|
||||
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
return {"ok": False, "path": "", "pip_done": False,
|
||||
"error": f"git clone fallo: {exc}"}
|
||||
if proc.returncode != 0:
|
||||
return {"ok": False, "path": "", "pip_done": False,
|
||||
"error": f"git clone fallo ({proc.returncode}): {proc.stderr.strip()[:300]}"}
|
||||
|
||||
notes = []
|
||||
if already:
|
||||
notes.append(f"ya existia en {dest} (no se re-clono)")
|
||||
|
||||
pip_done = False
|
||||
if pip_install:
|
||||
req = os.path.join(dest, "requirements.txt")
|
||||
if os.path.isfile(req):
|
||||
cmd = _pip_install_cmd(base, req)
|
||||
if cmd is None:
|
||||
notes.append(f"no se encontro instalador (pip/uv) para {base}/.venv (deps omitidas)")
|
||||
else:
|
||||
try:
|
||||
pproc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=_PIP_TIMEOUT,
|
||||
)
|
||||
pip_done = pproc.returncode == 0
|
||||
if not pip_done:
|
||||
notes.append(f"pip install fallo: {pproc.stderr.strip()[:300]}")
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
notes.append(f"pip install fallo: {exc}")
|
||||
else:
|
||||
notes.append("sin requirements.txt (nada que instalar)")
|
||||
|
||||
if restart:
|
||||
notes.append(
|
||||
"restart=True ignorado: reinicia ComfyUI manualmente cuando no haya "
|
||||
"generaciones en curso para cargar el nodo"
|
||||
)
|
||||
|
||||
return {"ok": True, "path": dest, "pip_done": pip_done, "error": "; ".join(notes)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
out = comfyui_install_custom_node(
|
||||
"https://github.com/rgthree/rgthree-comfy", restart=False,
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: comfyui_interrupt_queue
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
|
||||
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
||||
tags: [comfyui, ml, queue, interrupt, control, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
|
||||
|
||||
res = comfyui_interrupt_queue()
|
||||
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
|
||||
if res["ok"] and res["interrupted"]:
|
||||
print(f"cortado; pendientes en cola: {res['queue_pending']}")
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
|
||||
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
|
||||
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
|
||||
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
|
||||
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
|
||||
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
|
||||
+ lee).
|
||||
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
|
||||
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
|
||||
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
|
||||
`ok` antes de fiarte de los conteos.
|
||||
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
|
||||
trabajo pesado (esta funcion no lo hace).
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
|
||||
|
||||
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
|
||||
|
||||
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
|
||||
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
|
||||
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
|
||||
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
||||
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
|
||||
|
||||
Args:
|
||||
server: host:port del servidor ComfyUI sin esquema (default
|
||||
"127.0.0.1:8188").
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si tanto el interrupt como la lectura de la cola
|
||||
tuvieron exito.
|
||||
- interrupted (bool): True si el POST /interrupt respondio sin error.
|
||||
- queue_running (int): numero de prompts ejecutandose ahora mismo.
|
||||
- queue_pending (int): numero de prompts encolados pendientes.
|
||||
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {
|
||||
"ok": False,
|
||||
"interrupted": False,
|
||||
"queue_running": 0,
|
||||
"queue_pending": 0,
|
||||
"error": "",
|
||||
}
|
||||
base = f"http://{server}"
|
||||
|
||||
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
|
||||
try:
|
||||
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
|
||||
with urllib.request.urlopen(req, timeout=10.0):
|
||||
out["interrupted"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
|
||||
return out
|
||||
|
||||
# 2. GET /queue: estado actual de la cola tras el interrupt.
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
|
||||
data = json.loads(resp.read())
|
||||
out["queue_running"] = len(data.get("queue_running", []))
|
||||
out["queue_pending"] = len(data.get("queue_pending", []))
|
||||
out["ok"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"queue fallo: no se pudo conectar a {base}/queue: {reason}"
|
||||
except json.JSONDecodeError as exc:
|
||||
out["error"] = f"queue fallo: respuesta no es JSON valido: {exc}"
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = comfyui_interrupt_queue()
|
||||
print(
|
||||
f"ok={res['ok']} interrupted={res['interrupted']} "
|
||||
f"running={res['queue_running']} pending={res['queue_pending']} "
|
||||
f"error={res['error']!r}"
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: comfyui_list_installed_models
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_list_installed_models(folder: str | None = None, comfyui_dir: str = \"~/ComfyUI\") -> dict"
|
||||
description: "Lista los modelos instalados de ComfyUI por carpeta de tipo (checkpoints, loras, vae, controlnet, upscale_models), resolviendo las rutas REALES: escanea tanto la nativa <comfyui_dir>/models/<folder>/ como las externas declaradas en extra_model_paths.yaml (en este equipo /mnt/2tb/comfyui_models/). Escaneo de FS (no depende del servidor). Impura: lectura de disco + parse de YAML. Solo stdlib + PyYAML."
|
||||
tags: [comfyui, ml, models, inventory, filesystem, stable-diffusion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os", "yaml"]
|
||||
params:
|
||||
- name: folder
|
||||
desc: "Carpeta concreta a listar (ej. 'checkpoints'). Si None, lista todas (checkpoints, loras, vae, controlnet, upscale_models)."
|
||||
- name: comfyui_dir
|
||||
desc: "Raiz de la instalacion de ComfyUI (se expande ~). Default '~/ComfyUI'."
|
||||
output: "dict {ok, models, error}. models = {folder: [nombre, ...]} con los archivos de modelo (dedup por nombre) hallados en la ruta nativa models/<folder>/ y en las externas de extra_model_paths.yaml. ok=True salvo fallo de escaneo."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_list_installed_models.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_list_installed_models import comfyui_list_installed_models
|
||||
|
||||
out = comfyui_list_installed_models()
|
||||
print(out["models"]["checkpoints"])
|
||||
# ['dreamshaper_8.safetensors', 'juggernaut_xl_v11.safetensors', 'v1-5-pruned-emaonly-fp16.safetensors', ...]
|
||||
# -> resueltos desde /mnt/2tb/comfyui_models/checkpoints/ via extra_model_paths.yaml
|
||||
|
||||
# Una sola carpeta:
|
||||
loras = comfyui_list_installed_models(folder="loras")["models"]["loras"]
|
||||
```
|
||||
|
||||
El bloque se lanza con el python del venv (`python/.venv/bin/python3`).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites saber que checkpoints/LoRAs/VAEs/ControlNets/upscalers tienes ya
|
||||
descargados antes de construir un workflow (los builders necesitan el nombre exacto
|
||||
del modelo) o antes de descargar uno nuevo. Ve los modelos esten en `models/` o en
|
||||
el disco externo de `extra_model_paths.yaml`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee disco y parsea `extra_model_paths.yaml`. NO consulta el servidor de
|
||||
ComfyUI, asi que funciona aunque el server este reiniciandose.
|
||||
- **Resuelve la ruta REAL**: en este equipo los modelos viven en
|
||||
`/mnt/2tb/comfyui_models/` (no en `~/ComfyUI/models/`), declarado en
|
||||
`extra_model_paths.yaml`. La funcion lee ese YAML (incluida la sintaxis de
|
||||
carpeta multilinea) y suma esas rutas a la nativa, dedup por nombre.
|
||||
- Si `extra_model_paths.yaml` no existe o PyYAML no lo puede parsear, degrada a
|
||||
solo las rutas nativas `~/ComfyUI/models/<folder>/` (no falla).
|
||||
- Lista por nombre de archivo (no rutas completas) y solo extensiones de modelo
|
||||
(.safetensors, .ckpt, .pt, .pth, .bin, .gguf, .sft, .onnx). Subcarpetas dentro de
|
||||
cada folder NO se recorren (solo el primer nivel).
|
||||
- El catalogo que ve el SERVIDOR (combos de la UI) puede diferir si el server no se
|
||||
ha refrescado tras una descarga; para el combo en vivo usa `comfyui_object_info`
|
||||
o `comfyui_refresh_nodes_ui`.
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Lista los modelos instalados de ComfyUI por carpeta, resolviendo rutas reales.
|
||||
|
||||
ComfyUI puede leer los modelos desde rutas externas declaradas en
|
||||
`<comfyui_dir>/extra_model_paths.yaml` (en este equipo, /mnt/2tb/comfyui_models/),
|
||||
ademas de las nativas `<comfyui_dir>/models/<folder>/`. Esta funcion escanea
|
||||
AMBAS para cada carpeta de tipo (checkpoints, loras, vae, controlnet,
|
||||
upscale_models), de modo que ve los modelos aunque no esten bajo `models/`.
|
||||
|
||||
El escaneo es del sistema de archivos (no depende del servidor ComfyUI), asi que
|
||||
funciona aunque el servidor este reiniciandose.
|
||||
|
||||
Impura: lectura de disco (FS scan + parse de YAML). Solo stdlib + PyYAML.
|
||||
"""
|
||||
import os
|
||||
|
||||
_DEFAULT_FOLDERS = ["checkpoints", "loras", "vae", "controlnet", "upscale_models"]
|
||||
_MODEL_EXTS = (
|
||||
".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_external_roots(base: str) -> dict:
|
||||
"""Lee extra_model_paths.yaml y devuelve {folder: [dir_externo, ...]}.
|
||||
|
||||
Maneja valores de carpeta multilinea (varias subrutas por clave). Si el YAML
|
||||
no existe o no se puede parsear, devuelve {} (solo se usaran las rutas
|
||||
nativas).
|
||||
"""
|
||||
roots: dict = {}
|
||||
yml = os.path.join(base, "extra_model_paths.yaml")
|
||||
if not os.path.isfile(yml):
|
||||
return roots
|
||||
try:
|
||||
import yaml
|
||||
with open(yml, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
except Exception: # noqa: BLE001 — YAML ilegible: degradar a rutas nativas
|
||||
return roots
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return roots
|
||||
for section in data.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
bp = os.path.expanduser(str(section.get("base_path", "")))
|
||||
for key in _DEFAULT_FOLDERS:
|
||||
sub = section.get(key)
|
||||
if not sub:
|
||||
continue
|
||||
for line in str(sub).splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
roots.setdefault(key, []).append(os.path.join(bp, line))
|
||||
return roots
|
||||
|
||||
|
||||
def comfyui_list_installed_models(
|
||||
folder: str | None = None,
|
||||
comfyui_dir: str = "~/ComfyUI",
|
||||
) -> dict:
|
||||
"""Lista los modelos en disco por carpeta de tipo.
|
||||
|
||||
Args:
|
||||
folder: carpeta concreta a listar (ej. "checkpoints"). Si None, lista
|
||||
todas las de _DEFAULT_FOLDERS.
|
||||
comfyui_dir: raiz de la instalacion de ComfyUI (se expande ~).
|
||||
|
||||
Returns:
|
||||
dict {ok, models, error}. models es {folder: [nombre, ...]} con los
|
||||
archivos de modelo (dedup por nombre) hallados tanto en la ruta nativa
|
||||
`models/<folder>/` como en las externas de extra_model_paths.yaml. ok es
|
||||
True salvo fallo inesperado.
|
||||
"""
|
||||
base = os.path.expanduser(comfyui_dir)
|
||||
folders = [folder] if folder else list(_DEFAULT_FOLDERS)
|
||||
external = _resolve_external_roots(base)
|
||||
|
||||
models: dict = {}
|
||||
try:
|
||||
for f in folders:
|
||||
dirs = [os.path.join(base, "models", f)] + external.get(f, [])
|
||||
names: list = []
|
||||
seen: set = set()
|
||||
for d in dirs:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for entry in sorted(os.listdir(d)):
|
||||
if entry in seen:
|
||||
continue
|
||||
p = os.path.join(d, entry)
|
||||
if os.path.isfile(p) and entry.lower().endswith(_MODEL_EXTS):
|
||||
seen.add(entry)
|
||||
names.append(entry)
|
||||
models[f] = names
|
||||
except OSError as exc:
|
||||
return {"ok": False, "models": models, "error": f"fallo escaneando: {exc}"}
|
||||
|
||||
return {"ok": True, "models": models, "error": ""}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
print(json.dumps(comfyui_list_installed_models(), ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: comfyui_make_watertight
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_make_watertight(in_path: str, *, method: str = \"voxel\", pitch: float | None = None, out_path: str | None = None) -> dict"
|
||||
description: "Hace estanca (watertight) una malla 3D GLB/OBJ/PLY de ComfyUI/Hunyuan3D. method='voxel' (default) voxeliza el solido, rellena el interior y reconstruye con marching cubes (trimesh) -> is_watertight=True garantizado, a costa de mas caras y de descartar la apariencia; necesita scikit-image. method='repair' hace limpieza ligera (trimesh.repair fill_holes + fix_normals + fix_winding) conservando el detalle, pero no garantiza estanqueidad en mallas muy rotas. La via de raiz es generar con el nodo VoxelToMesh algorithm='surface net' (report 0088). Impura: lee y escribe en disco."
|
||||
tags: [comfyui, 3d, mesh, hunyuan3d, watertight, voxel, remesh, trimesh, ml]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: in_path
|
||||
desc: "Ruta de la malla de entrada (.glb/.obj/.ply/.gltf/.stl/.off). Scene GLB con varias geometrias se concatena en una sola malla."
|
||||
- name: method
|
||||
desc: "'voxel' (default, garantiza is_watertight=True via voxeliza+fill+marching cubes) o 'repair' (fill_holes + fix_normals, conserva detalle pero no siempre estanca). keyword-only."
|
||||
- name: pitch
|
||||
desc: "Solo para method='voxel'. Tamano de voxel absoluto. Si None, se calcula como diagonal_bbox / 200. Mas pequeno = mas caras y mas detalle, mas lento. keyword-only."
|
||||
- name: out_path
|
||||
desc: "Ruta de salida. Si None, escribe '<in>_watertight.glb' junto al original (NO sobrescribe). keyword-only."
|
||||
output: "dict {ok, was_watertight, is_watertight, out_path, method, pitch, out_faces, error}. was/is_watertight = estanqueidad antes/despues medida con trimesh.is_watertight. out_faces = caras del resultado (el voxel-remesh suele aumentarlas). Si falla, ok=False y error explica (dependencia ausente, method invalido, archivo no existe, carga o remesh)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_make_watertight.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_make_watertight import comfyui_make_watertight
|
||||
|
||||
# Cierra una malla decimada no estanca (80k caras) por voxel-remesh.
|
||||
res = comfyui_make_watertight(
|
||||
"/tmp/character_simplified.glb",
|
||||
method="voxel",
|
||||
out_path="/tmp/character_watertight.glb",
|
||||
)
|
||||
# res == {"ok": True, "was_watertight": False, "is_watertight": True,
|
||||
# "method": "voxel", "pitch": 0.01596, "out_faces": 250672,
|
||||
# "out_path": "/tmp/character_watertight.glb", "error": ""}
|
||||
```
|
||||
|
||||
Lanzar con el python del venv del registry (import de arriba o heredoc). `./fn run`
|
||||
directo no aplica: la firma usa `*` (keyword-only), no soportado por el runner de `fn run`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una malla de ComfyUI/Hunyuan3D sale NO estanca (`is_watertight=False`, tipico
|
||||
del nodo DEPRECATED `VoxelToMeshBasic`) y la necesitas cerrada para imprimir en 3D,
|
||||
calcular volumen, boolean ops o simulacion. Usa `method="voxel"` cuando exiges
|
||||
estanqueidad garantizada (la silueta se conserva, el detalle fino se suaviza).
|
||||
Usa `method="repair"` cuando la malla solo tiene huecos pequenos y quieres conservar
|
||||
caras/detalle. Para malla ligera Y estanca, decima antes con `comfyui_simplify_mesh`
|
||||
y luego pasa el resultado por aqui con `method="voxel"`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee `in_path` y escribe `out_path`. Nunca sobrescribe el original salvo
|
||||
que apuntes `out_path` a el.
|
||||
- `method="voxel"` DESCARTA la apariencia (UV / vertex colors): el marching cubes
|
||||
genera geometria nueva sin atributos. Si quieres color, aplicalo despues del
|
||||
remesh, o usa la via de raiz (`VoxelToMesh surface net`) que preserva el flujo de
|
||||
texturizado.
|
||||
- `method="voxel"` necesita `scikit-image` en el venv (marching cubes). Sin el
|
||||
devuelve ok=False con la instruccion `uv add scikit-image`.
|
||||
- `method="repair"` NO garantiza `is_watertight=True`: en mallas muy rotas
|
||||
(cube-soup con muchos huecos grandes) devuelve `is_watertight=False`. Es
|
||||
esperado; para garantia usa `method="voxel"`.
|
||||
- El voxel-remesh aumenta el numero de caras (densidad del marching cubes). Si
|
||||
necesitas ligero Y estanco, vuelve a decimar el resultado con
|
||||
`comfyui_simplify_mesh` (el report 0088 logra 80k caras + estanco re-cerrando).
|
||||
- `euler_number` puede quedar negativo aunque sea estanco: indica genus alto real
|
||||
(tuneles de la geometria), no un fallo. Estanco != genus 0.
|
||||
- `pitch` muy pequeno sobre mallas grandes es lento (corre en CPU, no usa VRAM).
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Hace estanca (watertight) una malla 3D GLB/OBJ/PLY.
|
||||
|
||||
Post-proceso de las mallas de ComfyUI/Hunyuan3D producidas con el nodo
|
||||
VoxelToMeshBasic (DEPRECATED), que genera mallas NO estancas (is_watertight=False):
|
||||
crea 4 vertices nuevos por cara expuesta sin soldarlos, dejando huecos y bordes
|
||||
non-manifold. Dos metodos:
|
||||
|
||||
- method="voxel" (default, garantiza is_watertight=True): voxeliza el solido,
|
||||
rellena el interior y reconstruye la superficie con marching cubes
|
||||
(trimesh voxelized(pitch).fill().marching_cubes). Produce una malla cerrada por
|
||||
construccion. Coste: mas caras (densidad del marching cubes) y descarta la
|
||||
apariencia (UV/vertex colors). Necesita scikit-image (marching cubes).
|
||||
- method="repair": limpieza ligera con trimesh.repair (fix_winding + fill_holes +
|
||||
fix_normals + merge_vertices). Conserva el detalle y las caras, pero NO garantiza
|
||||
estanqueidad en mallas muy rotas (solo cierra huecos pequenos).
|
||||
|
||||
La via de RAIZ (no este post-proceso) es generar con el nodo VoxelToMesh
|
||||
algorithm='surface net', que da malla manifold cerrada sin reparar (ver report 0088).
|
||||
|
||||
Impura: lee y escribe archivos en disco. Requiere trimesh (+ scikit-image para voxel).
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _load_mesh(path):
|
||||
import trimesh
|
||||
|
||||
obj = trimesh.load(path, process=False)
|
||||
if isinstance(obj, trimesh.Scene):
|
||||
obj = trimesh.util.concatenate(list(obj.geometry.values()))
|
||||
return obj
|
||||
|
||||
|
||||
def comfyui_make_watertight(
|
||||
in_path: str,
|
||||
*,
|
||||
method: str = "voxel",
|
||||
pitch: float | None = None,
|
||||
out_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Hace estanca una malla GLB/OBJ/PLY por voxel-remesh o reparacion.
|
||||
|
||||
Args:
|
||||
in_path: ruta de la malla de entrada (.glb/.obj/.ply/.gltf/.stl/.off).
|
||||
method: "voxel" (default, garantiza is_watertight=True via voxeliza+fill+
|
||||
marching cubes) o "repair" (fill_holes + fix_normals, conserva detalle
|
||||
pero no siempre estanca). keyword-only.
|
||||
pitch: solo para method="voxel". Tamano de voxel absoluto. Si None, se
|
||||
calcula como diagonal_bbox / 200 (mas fino = mas caras y detalle).
|
||||
keyword-only.
|
||||
out_path: ruta de salida. Si None, escribe "<in>_watertight.glb" junto al
|
||||
original. keyword-only.
|
||||
|
||||
Returns:
|
||||
dict {ok, was_watertight, is_watertight, out_path, method, pitch, out_faces,
|
||||
error}. was/is_watertight = estanqueidad antes/despues (trimesh). Si falla,
|
||||
ok=False y error explica.
|
||||
"""
|
||||
base_err = {
|
||||
"ok": False, "was_watertight": None, "is_watertight": None,
|
||||
"out_path": "", "method": method, "pitch": None, "out_faces": 0,
|
||||
}
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError as exc:
|
||||
return {**base_err, "error": f"falta trimesh: {exc}. cd python && uv add trimesh"}
|
||||
|
||||
if method not in ("voxel", "repair"):
|
||||
return {**base_err, "error": f"method '{method}' invalido (usa 'voxel' o 'repair')"}
|
||||
if not os.path.exists(in_path):
|
||||
return {**base_err, "error": f"no existe el archivo de entrada: {in_path!r}"}
|
||||
if out_path is None:
|
||||
out_path = os.path.splitext(in_path)[0] + "_watertight.glb"
|
||||
|
||||
try:
|
||||
mesh = _load_mesh(in_path)
|
||||
except Exception as exc:
|
||||
return {**base_err, "error": f"no se pudo cargar la malla {in_path!r}: {exc}"}
|
||||
was = bool(mesh.is_watertight)
|
||||
|
||||
try:
|
||||
if method == "voxel":
|
||||
m = mesh.copy()
|
||||
m.merge_vertices()
|
||||
if pitch is None:
|
||||
diag = float(np.linalg.norm(m.extents))
|
||||
pitch = diag / 200.0
|
||||
vg = m.voxelized(pitch=float(pitch)).fill()
|
||||
out = vg.marching_cubes
|
||||
out.merge_vertices()
|
||||
trimesh.repair.fix_normals(out)
|
||||
else: # repair
|
||||
out = mesh.copy()
|
||||
out.merge_vertices()
|
||||
trimesh.repair.fix_winding(out)
|
||||
trimesh.repair.fill_holes(out)
|
||||
trimesh.repair.fix_normals(out)
|
||||
|
||||
parent = os.path.dirname(out_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
out.export(out_path)
|
||||
except ImportError as exc:
|
||||
return {**base_err, "was_watertight": was,
|
||||
"error": f"falta dependencia para method='{method}': {exc}. "
|
||||
f"El voxel-remesh necesita scikit-image: cd python && uv add scikit-image"}
|
||||
except Exception as exc:
|
||||
return {**base_err, "was_watertight": was,
|
||||
"error": f"fallo en method='{method}': {type(exc).__name__}: {exc}"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"was_watertight": was,
|
||||
"is_watertight": bool(out.is_watertight),
|
||||
"out_path": out_path,
|
||||
"method": method,
|
||||
"pitch": round(float(pitch), 6) if pitch is not None else None,
|
||||
"out_faces": int(len(out.faces)),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else (
|
||||
os.path.expanduser("~/ComfyUI/output/3d_robot_mesh_00001__dec80k.glb"))
|
||||
method = sys.argv[2] if len(sys.argv) > 2 else "voxel"
|
||||
out = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
print(json.dumps(comfyui_make_watertight(src, method=method, out_path=out), indent=2))
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: comfyui_object_info
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_object_info(server: str = \"127.0.0.1:8188\", node_class: str | None = None, timeout: float = 30.0) -> dict"
|
||||
description: "Consulta el catalogo de nodos de un servidor ComfyUI via GET /object_info (o un nodo concreto con /object_info/{node_class}). Devuelve specs de inputs y valores enumerados (ej. lista de checkpoints visibles). Impura: HTTP GET, solo stdlib."
|
||||
tags: [comfyui, ml, image-generation, stable-diffusion, introspection, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||
- name: node_class
|
||||
desc: "Si se pasa, consulta solo ese class_type via /object_info/{node_class} (ej. 'CheckpointLoaderSimple'). None devuelve el catalogo completo."
|
||||
- name: timeout
|
||||
desc: "Timeout de la peticion HTTP en segundos."
|
||||
output: "dict del catalogo. Con node_class=None es {class_type: spec, ...} (cientos de nodos). Con node_class set, {class_type: spec} de un solo item. Cada spec tiene input.required/optional con tipos y enums; ej. info['CheckpointLoaderSimple']['input']['required']['ckpt_name'][0] es la lista de checkpoints."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_object_info.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_object_info import comfyui_object_info
|
||||
|
||||
info = comfyui_object_info() # catalogo completo
|
||||
print(len(info)) # ~792 nodos
|
||||
ckpts = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
|
||||
print(ckpts) # ['v1-5-pruned-emaonly-fp16.safetensors']
|
||||
|
||||
ks = comfyui_object_info(node_class="KSampler") # solo un nodo
|
||||
print(list(ks.keys())) # ['KSampler']
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_object_info` (imprime n nodos + checkpoints visibles).
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de construir o enviar un workflow: descubre que checkpoints, samplers,
|
||||
schedulers y nodos existen en el servidor concreto. Usala para validar que el
|
||||
`ckpt_name` que vas a poner en `comfyui_build_txt2img_workflow` existe, o para
|
||||
explorar nodos disponibles (LoRA loaders, upscalers, ControlNet) antes de
|
||||
componer workflows mas ricos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El catalogo completo es grande (cientos de nodos): preferir `node_class` si
|
||||
solo necesitas uno.
|
||||
- Los valores enumerados (checkpoints, vaes, loras) reflejan lo que el SERVIDOR
|
||||
ve en sus carpetas models/, no lo que hay en tu disco local. Si acabas de
|
||||
copiar un checkpoint, el servidor puede no haberlo escaneado hasta reiniciar o
|
||||
refrescar.
|
||||
- Lanza RuntimeError si ComfyUI no esta arriba (conexion rechazada) o responde
|
||||
con error. El catalogo solo esta disponible con el servidor corriendo.
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Consulta el catalogo de nodos de un servidor ComfyUI via GET /object_info.
|
||||
|
||||
Funcion impura: hace red (HTTP GET). Solo stdlib (urllib, json).
|
||||
|
||||
El catalogo describe cada class_type disponible: sus inputs requeridos y
|
||||
opcionales, sus tipos, y los valores enumerados (ej. la lista de checkpoints
|
||||
visibles para el servidor en CheckpointLoaderSimple). Util para validar un
|
||||
workflow antes de enviarlo y para descubrir que checkpoints/samplers existen.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def comfyui_object_info(
|
||||
server: str = "127.0.0.1:8188",
|
||||
node_class: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Recupera el catalogo de nodos (o uno concreto) de ComfyUI.
|
||||
|
||||
Args:
|
||||
server: host:port del servidor ComfyUI (sin esquema).
|
||||
node_class: si se pasa, consulta solo ese class_type via
|
||||
GET /object_info/{node_class} (ej. "CheckpointLoaderSimple").
|
||||
Si es None, devuelve el catalogo completo (GET /object_info).
|
||||
timeout: timeout de la peticion HTTP en segundos.
|
||||
|
||||
Returns:
|
||||
dict con el catalogo. Con node_class=None es {class_type: spec, ...}.
|
||||
Con node_class set, ComfyUI devuelve {class_type: spec} (un solo item).
|
||||
|
||||
Raises:
|
||||
RuntimeError: si la peticion HTTP falla (conexion rechazada, timeout,
|
||||
HTTP de error) o si la respuesta no es JSON valido. El mensaje
|
||||
incluye el cuerpo del error cuando ComfyUI lo provee.
|
||||
"""
|
||||
path = "/object_info"
|
||||
if node_class is not None:
|
||||
path = f"/object_info/{urllib.parse.quote(node_class)}"
|
||||
url = f"http://{server}{path}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")
|
||||
raise RuntimeError(
|
||||
f"comfyui_object_info: HTTP {exc.code} en {url}: {body}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"comfyui_object_info: no se pudo conectar a {url}: {exc.reason}"
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"comfyui_object_info: respuesta no es JSON valido desde {url}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
info = comfyui_object_info()
|
||||
print(f"nodos disponibles: {len(info)}")
|
||||
ckpts = info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
|
||||
print(f"checkpoints visibles: {ckpts}")
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: comfyui_queue_manage
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_queue_manage(action: str, *, server: str = \"127.0.0.1:8188\", prompt_id: str | None = None) -> dict"
|
||||
description: "Gestiona la cola y el historial de ComfyUI via su API HTTP. action='status' (GET /queue -> queue_running/queue_pending), 'clear' (POST /queue {\"clear\":true} -> vacia pendientes), 'delete' (POST /queue {\"delete\":[prompt_id]} -> borra un prompt, requiere prompt_id), 'history' (GET /history -> history_count). Completa lo que comfyui_interrupt_queue no cubre. Devuelve {ok, action, queue_running, queue_pending, history_count, error}. NO lanza en fallo de red: degrada a {ok:False, error}. Impura: HTTP GET/POST, solo stdlib (urllib, json)."
|
||||
tags: [comfyui, ml, queue, history, control, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: action
|
||||
desc: "operacion: 'status' (estado de la cola), 'clear' (vaciar pendientes), 'delete' (borrar un prompt; requiere prompt_id), 'history' (contar historial)."
|
||||
- name: server
|
||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||
- name: prompt_id
|
||||
desc: "id del prompt a borrar; obligatorio solo para action='delete'."
|
||||
output: "dict con ok (bool), action (str, eco), queue_running (int, prompts ejecutandose; status/clear/delete), queue_pending (int, prompts encolados; status/clear/delete), history_count (int, prompts en el historial; action='history'), error (str, vacio si OK)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_queue_manage.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_queue_manage import comfyui_queue_manage
|
||||
|
||||
# Estado de la cola
|
||||
st = comfyui_queue_manage("status")
|
||||
# {'ok': True, 'action': 'status', 'queue_running': 1, 'queue_pending': 3, 'history_count': 0, 'error': ''}
|
||||
|
||||
# Cuantos prompts recuerda el historial
|
||||
h = comfyui_queue_manage("history")
|
||||
print(h["history_count"])
|
||||
|
||||
# Vaciar los pendientes (no corta el que se ejecuta; para eso, comfyui_interrupt_queue)
|
||||
comfyui_queue_manage("clear")
|
||||
|
||||
# Borrar un prompt concreto de la cola de pendientes
|
||||
comfyui_queue_manage("delete", prompt_id="abc123-...")
|
||||
```
|
||||
|
||||
O lanzable directo: `./fn run comfyui_queue_manage status` · `./fn run comfyui_queue_manage history`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas operar la cola mas alla de cortar el prompt en curso: ver de un
|
||||
vistazo cuanto queda (`status`), limpiar de golpe un barrido de seeds que ya no
|
||||
quieres (`clear`), quitar un prompt pesado encolado por error sin matar el que se
|
||||
ejecuta (`delete`), o saber cuantas generaciones recuerda el servidor (`history`).
|
||||
Es el complemento de `comfyui_interrupt_queue` (que solo corta + lee) para cubrir
|
||||
las cuatro acciones restantes de `/queue` y `/history`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `clear` vacia SOLO los pendientes; el prompt en ejecucion sigue. Para cortarlo
|
||||
usa `comfyui_interrupt_queue` (POST /interrupt) antes del `clear`.
|
||||
- `delete` requiere `prompt_id`; sin el devuelve `ok=False` con el error. El id es
|
||||
el que devuelve `comfyui_submit_workflow`. Borrar un prompt que ya no esta en la
|
||||
cola es inocuo (el servidor lo ignora).
|
||||
- En `status`/`clear`/`delete` se rellenan `queue_running`/`queue_pending`; en
|
||||
`history` se rellena `history_count` (los otros quedan en 0). Mira `action` para
|
||||
saber que campos son significativos.
|
||||
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`.
|
||||
Comprueba `ok` antes de fiarte de los conteos.
|
||||
- `history_count` es el numero de entradas que el servidor mantiene en memoria, no
|
||||
un acumulado historico persistente: se reinicia al reiniciar ComfyUI.
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Gestiona la cola y el historial de un servidor ComfyUI via su API HTTP.
|
||||
|
||||
Funcion impura: hace red (HTTP GET/POST). Solo stdlib (urllib, json).
|
||||
|
||||
Completa lo que comfyui_interrupt_queue no cubre. interrupt_queue corta el prompt
|
||||
en ejecucion; esta funcion expone las cuatro operaciones restantes de la cola:
|
||||
|
||||
- "status": GET /queue -> cuantos prompts se ejecutan ahora (queue_running) y
|
||||
cuantos estan encolados pendientes (queue_pending).
|
||||
- "clear": POST /queue {"clear": true} -> vacia los pendientes de golpe.
|
||||
- "delete": POST /queue {"delete": [prompt_id]} -> borra un prompt concreto de la
|
||||
cola de pendientes (requiere prompt_id).
|
||||
- "history": GET /history -> numero de prompts ya ejecutados que el servidor
|
||||
recuerda (history_count).
|
||||
|
||||
NO lanza excepcion en fallo de red: degrada a {ok: False, error}.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def comfyui_queue_manage(
|
||||
action: str,
|
||||
*,
|
||||
server: str = "127.0.0.1:8188",
|
||||
prompt_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Opera la cola/historial de ComfyUI: status, clear, delete o history.
|
||||
|
||||
Args:
|
||||
action: operacion a realizar. Una de:
|
||||
- "status": lee el estado de la cola.
|
||||
- "clear": vacia los prompts pendientes (POST /queue {"clear": true}).
|
||||
- "delete": borra un prompt concreto (POST /queue {"delete": [id]});
|
||||
requiere prompt_id.
|
||||
- "history": cuenta los prompts en el historial (GET /history).
|
||||
server: host:port del servidor ComfyUI sin esquema (default
|
||||
"127.0.0.1:8188"). keyword-only.
|
||||
prompt_id: id del prompt a borrar; obligatorio solo para action="delete".
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si la operacion se completo sin error.
|
||||
- action (str): la accion solicitada (eco).
|
||||
- queue_running (int): prompts ejecutandose ahora (status/clear/delete).
|
||||
- queue_pending (int): prompts encolados pendientes (status/clear/delete).
|
||||
- history_count (int): numero de prompts en el historial (action=history).
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {
|
||||
"ok": False,
|
||||
"action": action,
|
||||
"queue_running": 0,
|
||||
"queue_pending": 0,
|
||||
"history_count": 0,
|
||||
"error": "",
|
||||
}
|
||||
base = f"http://{server}"
|
||||
valid = {"status", "clear", "delete", "history"}
|
||||
if action not in valid:
|
||||
out["error"] = f"action desconocida: {action!r}; usa una de {sorted(valid)}"
|
||||
return out
|
||||
|
||||
def _read_queue() -> bool:
|
||||
"""Rellena queue_running/queue_pending desde GET /queue. True si OK."""
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
|
||||
data = json.loads(resp.read())
|
||||
out["queue_running"] = len(data.get("queue_running", []))
|
||||
out["queue_pending"] = len(data.get("queue_pending", []))
|
||||
return True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"GET /queue fallo: no se pudo conectar a {base}/queue: {reason}"
|
||||
except json.JSONDecodeError as exc:
|
||||
out["error"] = f"GET /queue fallo: respuesta no es JSON valido: {exc}"
|
||||
return False
|
||||
|
||||
def _post_queue(body: dict) -> bool:
|
||||
"""POST /queue con cuerpo JSON. True si el servidor respondio sin error."""
|
||||
try:
|
||||
payload = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base}/queue",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10.0):
|
||||
return True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"POST /queue fallo: no se pudo conectar a {base}/queue: {reason}"
|
||||
return False
|
||||
|
||||
if action == "status":
|
||||
out["ok"] = _read_queue()
|
||||
return out
|
||||
|
||||
if action == "clear":
|
||||
if _post_queue({"clear": True}):
|
||||
out["ok"] = _read_queue()
|
||||
return out
|
||||
|
||||
if action == "delete":
|
||||
if not prompt_id:
|
||||
out["error"] = "action='delete' requiere prompt_id"
|
||||
return out
|
||||
if _post_queue({"delete": [prompt_id]}):
|
||||
out["ok"] = _read_queue()
|
||||
return out
|
||||
|
||||
# action == "history"
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/history", timeout=15.0) as resp:
|
||||
hist = json.loads(resp.read())
|
||||
out["history_count"] = len(hist) if isinstance(hist, dict) else 0
|
||||
out["ok"] = True
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
out["error"] = f"GET /history fallo: no se pudo conectar a {base}/history: {reason}"
|
||||
except json.JSONDecodeError as exc:
|
||||
out["error"] = f"GET /history fallo: respuesta no es JSON valido: {exc}"
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
act = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
pid = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
res = comfyui_queue_manage(act, prompt_id=pid)
|
||||
print(json.dumps(res, indent=2))
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: comfyui_read_png_metadata
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def comfyui_read_png_metadata(png_path: str) -> dict"
|
||||
description: "Lee los parametros de generacion de un PNG generado por ComfyUI: extrae el chunk 'prompt' (API format) y resume modelo, seed, steps, cfg, sampler, scheduler, denoise y los prompts positivo/negativo siguiendo las conexiones del KSampler. Comparte el lector de chunks PNG con comfyui_import_workflow_png. Impura: lectura de disco, solo stdlib."
|
||||
tags: [comfyui, ml, png, metadata, workflow, stable-diffusion]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
params:
|
||||
- name: png_path
|
||||
desc: "Ruta local del PNG generado por ComfyUI."
|
||||
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||
from ml.comfyui_read_png_metadata import comfyui_read_png_metadata
|
||||
|
||||
res = comfyui_read_png_metadata(os.path.expanduser("~/ComfyUI/output/comfy_00001_.png"))
|
||||
# res["ok"] == True
|
||||
# res["parameters"]["seed"] # ej. 20260623
|
||||
# res["parameters"]["model"] # ej. "dreamshaper_8.safetensors"
|
||||
# res["parameters"]["positive"] # el prompt positivo usado
|
||||
```
|
||||
|
||||
O lanzable directo con: `./fn run comfyui_read_png_metadata <ruta.png>`.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras saber con que parametros se genero una imagen (que seed, modelo o
|
||||
prompt) sin abrir el grafo entero: para reproducir una imagen que te gusto, para
|
||||
catalogar outputs, o para comparar generaciones. Si necesitas el workflow completo
|
||||
para relanzarlo usa `comfyui_import_workflow_png` (devuelve el dict entero).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: lee el archivo del disco. Un path inexistente o un PNG sin chunk
|
||||
'prompt' devuelve `{ok: False, error: ...}` (no lanza).
|
||||
- `parameters` se extrae del primer nodo cuyo class_type acaba en "KSampler" y de
|
||||
los CLIPTextEncode conectados a sus inputs positive/negative. Workflows muy
|
||||
custom (varios samplers, sin CheckpointLoaderSimple) pueden dar `parameters`
|
||||
parcial; el `prompt` completo siempre se devuelve para inspeccion manual.
|
||||
- Lee chunks tEXt/zTXt/iTXt; los PNG de la API REST solo traen 'prompt' (no
|
||||
'workflow'), suficiente para los parametros.
|
||||
- Marcada impura (no pura) porque hace I/O de disco, segun la regla de pureza del
|
||||
registry; la logica de parseo en si es determinista.
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Lee los parametros de generacion de un PNG generado por ComfyUI.
|
||||
|
||||
Extrae el chunk "prompt" (API format) de los chunks de texto del PNG y resume
|
||||
los parametros de generacion: modelo, seed, steps, cfg, sampler, scheduler,
|
||||
denoise y los prompts positivo/negativo (siguiendo las conexiones del KSampler).
|
||||
|
||||
Impura: lectura de disco. Solo stdlib (struct, zlib, json).
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
def comfyui_read_png_metadata(png_path: str) -> dict:
|
||||
"""Devuelve {ok, prompt, parameters, error} de un PNG de ComfyUI.
|
||||
|
||||
Args:
|
||||
png_path: ruta del PNG generado por ComfyUI.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok: bool.
|
||||
- prompt: el workflow API format embebido (dict), o {}.
|
||||
- parameters: resumen {model, seed, steps, cfg, sampler_name,
|
||||
scheduler, denoise, positive, negative} extraido del KSampler y los
|
||||
nodos conectados, o {}.
|
||||
- error: mensaje si algo fallo.
|
||||
"""
|
||||
try:
|
||||
with open(png_path, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError as exc:
|
||||
return {"ok": False, "prompt": {}, "parameters": {},
|
||||
"error": f"no se pudo leer {png_path!r}: {exc}"}
|
||||
try:
|
||||
chunks = _png_text_chunks(data)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "prompt": {}, "parameters": {}, "error": str(exc)}
|
||||
|
||||
if "prompt" not in chunks:
|
||||
return {"ok": False, "prompt": {}, "parameters": {},
|
||||
"error": "el PNG no contiene chunk 'prompt' de ComfyUI"}
|
||||
try:
|
||||
prompt = json.loads(chunks["prompt"])
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"ok": False, "prompt": {}, "parameters": {},
|
||||
"error": f"chunk 'prompt' no es JSON valido: {exc}"}
|
||||
|
||||
return {"ok": True, "prompt": prompt, "parameters": _extract_params(prompt), "error": ""}
|
||||
|
||||
|
||||
def _extract_params(prompt: dict) -> dict:
|
||||
params = {}
|
||||
ksampler = None
|
||||
for node in prompt.values():
|
||||
if isinstance(node, dict) and str(node.get("class_type", "")).endswith("KSampler"):
|
||||
ksampler = node
|
||||
break
|
||||
if ksampler:
|
||||
ins = ksampler.get("inputs", {})
|
||||
for k in ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"):
|
||||
if k in ins and not isinstance(ins[k], list):
|
||||
params[k] = ins[k]
|
||||
for slot in ("positive", "negative"):
|
||||
link = ins.get(slot)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
tnode = prompt.get(str(link[0]), {})
|
||||
txt = tnode.get("inputs", {}).get("text")
|
||||
if isinstance(txt, str):
|
||||
params[slot] = txt
|
||||
for node in prompt.values():
|
||||
if isinstance(node, dict) and str(node.get("class_type", "")).startswith("CheckpointLoader"):
|
||||
ck = node.get("inputs", {}).get("ckpt_name")
|
||||
if ck:
|
||||
params["model"] = ck
|
||||
break
|
||||
return params
|
||||
|
||||
|
||||
def _png_text_chunks(data: bytes) -> dict:
|
||||
"""Lee los chunks de texto (tEXt/zTXt/iTXt) de un PNG -> {keyword: texto}."""
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise ValueError("no es un PNG valido (firma incorrecta)")
|
||||
out = {}
|
||||
off = 8
|
||||
n = len(data)
|
||||
while off + 8 <= n:
|
||||
length = struct.unpack(">I", data[off:off + 4])[0]
|
||||
ctype = data[off + 4:off + 8]
|
||||
body = data[off + 8:off + 8 + length]
|
||||
off += 12 + length
|
||||
if ctype == b"tEXt":
|
||||
kw, _, txt = body.partition(b"\x00")
|
||||
out[kw.decode("latin1")] = txt.decode("latin1")
|
||||
elif ctype == b"zTXt":
|
||||
kw, _, rest = body.partition(b"\x00")
|
||||
if rest:
|
||||
try:
|
||||
out[kw.decode("latin1")] = zlib.decompress(rest[1:]).decode("latin1")
|
||||
except zlib.error:
|
||||
pass
|
||||
elif ctype == b"iTXt":
|
||||
kw, _, rest = body.partition(b"\x00")
|
||||
if len(rest) >= 2:
|
||||
comp_flag = rest[0]
|
||||
parts = rest[2:].split(b"\x00", 2)
|
||||
if len(parts) == 3:
|
||||
text_bytes = parts[2]
|
||||
if comp_flag == 1:
|
||||
try:
|
||||
text_bytes = zlib.decompress(text_bytes)
|
||||
except zlib.error:
|
||||
text_bytes = b""
|
||||
out[kw.decode("latin1")] = text_bytes.decode("utf-8", "replace")
|
||||
elif ctype == b"IEND":
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/missing.png"
|
||||
res = comfyui_read_png_metadata(path)
|
||||
print(json.dumps({"ok": res["ok"], "parameters": res["parameters"], "error": res["error"]}, indent=2))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user