Compare commits

...

8 Commits

Author SHA1 Message Date
Egutierrez 8e9e1e6c8a feat(comfyui): pipeline comfyui_generate_until_quality (loop evaluator-optimizer)
Loop tipo GAN sin entrenar: genera con un builder del registry, juzga con el
panel multi-juez (comfyui_judge_image) y, si no alcanza el umbral, refina (nueva
seed, mas steps/cfg, prompt corregido con el feedback del juez via ask_llm) y
regenera hasta converger (verdict 'good') o agotar max_iters. Devuelve siempre
la mejor candidata por score (best-of-N), nunca lanza excepcion cruda.

Compone comfyui_submit_workflow + comfyui_wait_result + comfyui_fetch_output_image
+ comfyui_judge_image + ask_llm. Filtra kwargs por inspect.signature para ser
robusto entre builders. Caso HUD verificado: itera iter0 bad -> iter1 good.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:01:37 +02:00
egutierrez ec46aae04c chore: auto-commit (1 archivos)
- logs/ardour_mcp_server.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 07:34:26 +02:00
egutierrez b173ac2703 merge(comfyui): higiene capability pages (drift conteos + styles + audio/templates + build_flux + parallax) 2026-06-28 07:34:02 +02:00
egutierrez ec0a5e53ac docs(comfyui): remata drift de conteo en comfyui-skill (11→17) y gamedev-2d (36→47)
- gamedev-2d.md: el header decía '31 builders + 5 de apoyo' (=36); inventario real = 47
  funciones (36 builders: 31 de generación + 5 de transformación; 11 de apoyo: post-proceso,
  puente a Godot, style presets, pipelines one-shot).
- comfyui-skill.md: añade bloque de tamaño del grupo (17 funciones tag comfyui-skill); la
  página no tenía conteo interno (el 11 obsoleto vivía solo en INDEX.md).
- INDEX.md: gamedev-2d 36→47 y comfyui-skill 11→17, con descripciones actualizadas.

Cierra el drift residual señalado en el report 0210.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:33:08 +02:00
egutierrez 5280499df5 merge(comfyui): tests offline para 16 builders puros (376 tests verdes) + tested:true 2026-06-28 07:32:15 +02:00
egutierrez 346f859b86 test(comfyui): tests offline para 15 builders/funciones puras sin test
Cubre 15 funciones del grupo comfyui (+ las 4 de comfyui-judge) que no tenian
test, con tests offline (sin red, sin GPU, sin servidor ComfyUI):

- 5 builders puros gamedev-2d: build_asset_variant, build_directional_sprite,
  build_inpaint_asset, build_outpaint_asset, build_sprite_from_sketch (estructura
  del workflow en API format + cableado + determinismo + error paths).
- 3 impuras offline via PIL/stdlib: build_grid, flatten_alpha_on_color,
  read_png_metadata (PNGs reales en tmp, error paths).
- 4 de comfyui-judge: score_aesthetic y score_clip_alignment por sus guards
  previos al subproceso torch; judge_image (panel) y critique_image_llm con la
  dependencia pesada monkeypatcheada.
- 3 que componen otras funciones: resolve_workflow_deps, import_workflow_json,
  extract_recipe_from_png (dependencia de red monkeypatcheada o fallback offline).

Cada .md actualizado con tested: true + test_file_path + tests.
Cobertura del grupo comfyui (tag plano): 79 -> 90 con test (47 -> 36 sin).
comfyui-judge: 0/4 -> 4/4. pytest: 101 passed; carpeta ml/tests: 376 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:30:59 +02:00
egutierrez 604d3d4feb docs(comfyui): higiene de capability pages — drift 29→126 + styles + build_flux + parallax
- comfyui.md: bloque de tamaño real del grupo (126 funciones tag comfyui: 63 puras,
  50 impuras, 13 pipelines) con punteros a los sub-grupos (comfyui-skill, comfyui-styles,
  comfyui-judge, gamedev-2d). Corrige la firma corta de build_flux (variant/steps=None/
  weight_dtype='default' + camino custom-advanced) que arrastraba drift del report 0205.
  Añade sección Styles con las 5 funciones del sub-grupo.
- comfyui-styles.md (NUEVA): página madre del sub-grupo de estilo (catálogo WAS +
  style presets gamedev), tabla de las 5 funciones, ejemplos canónicos alineados con
  los retornos reales y fronteras.
- comfyui-overview.md: añade audio (05b) y styles (04b) al mapa cross-grupo y a la tabla
  resumen; referencia las nuevas páginas madre comfyui-styles y gamedev-2d.
- INDEX.md: comfyui 29→126 con descripción actualizada; nueva fila comfyui-styles.
- comfyui_build_parallax_background_workflow.md: añade sección ## Ejemplo lanzable
  (el indexer extrae example del cuerpo, no del frontmatter) — cobertura del grupo
  pasa a 126/126 con ejemplo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:27:32 +02:00
egutierrez 287abbd6ee merge(comfyui): fix firmas keyword-only para que fn run despache (5 funciones de skills) 2026-06-28 07:26:02 +02:00
40 changed files with 1828 additions and 41 deletions
+4 -3
View File
@@ -14,7 +14,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| Grupo | N | Que cubre |
|---|---|---|
| [gamedev-2d](gamedev-2d.md) | 36 | Assets 2D para Godot via ComfyUI: 31 builders de workflow (pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX...) + 5 de apoyo: post-proceso (pixelize, luma->alpha) + puente de assets a Godot 4 (.import + reimport headless). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
@@ -72,8 +72,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
| [comfyui](comfyui.md) | 126 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
| [comfyui-skill](comfyui-skill.md) | 17 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
| [comfyui-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
## Como anadir grupo
+14
View File
@@ -8,7 +8,9 @@ Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
- [comfyui-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
@@ -25,7 +27,9 @@ Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
@@ -67,6 +71,16 @@ sus IDs reales cuando se ejecute `fn index`.
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
### 04b · styles (presets / catálogo)
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
### 05 · video
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
+7
View File
@@ -12,6 +12,13 @@ submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
## Qué es una skill
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
+101
View File
@@ -0,0 +1,101 @@
# ComfyUI Styles — presets y catálogo de estilo
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
prompt, el estilo se trata como un dato curado y reusable.
Dos vertientes complementarias:
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
## Funciones del grupo
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
generar un asset con look consistente.
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
from ml.comfyui_append_styles import comfyui_append_styles
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
# directo (best-effort: {} si el LLM falla).
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
if nuevos:
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
```
### B) Aplicar un style preset gamedev a un sujeto
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
wf = comfyui_build_enemy_creature_workflow(
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
)
```
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
```python
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
print(len(todos), list(todos)[:5])
```
## Fronteras
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
dict de modificadores, pero el selector no aparecerá en el grafo.
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
aquí por afinidad de capacidad (estilo reutilizable).
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
la pone el `subject` del usuario.
+28 -1
View File
@@ -13,6 +13,17 @@ Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/c
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
> propia página. El mapa cross-grupo de capacidades está en
> [comfyui-overview.md](comfyui-overview.md).
## Dos caminos, mismo motor
```
@@ -44,7 +55,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow`
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. |
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **Pura**. |
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
@@ -207,6 +218,22 @@ un error accionable, sin lanzar.
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
+3 -2
View File
@@ -11,8 +11,9 @@ Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
con sus import settings.
Tag único del grupo: `gamedev-2d` (los 31 builders de workflow + las 5 funciones de
apoyo de post-proceso y puente). El tag plano `gamedev` quedó deprecado y unificado a
Tag único del grupo: `gamedev-2d` **47 funciones**: 36 builders de workflow (31 de
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
File diff suppressed because one or more lines are too long
@@ -49,7 +49,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
tested: false
tested: true
tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py"
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
---
@@ -44,7 +44,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
tested: false
tested: true
tests: ["sv3d: estructura + orbit (video_frames=directions, size nativa 576)", "orbit_frames override", "zero123: StableZero123_Conditioning_Batched, azimuth equiespaciado, size 256", "cfg/ckpt por defecto segun modelo", "elevation/seed reflejados", "directional_sprite_view_order para 4/8/N", "errores: input vacio, model invalido, directions<1", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py"
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
---
+3 -3
View File
@@ -26,9 +26,9 @@ params:
- 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: ""
tested: true
tests: ["grid basico: ok + out_path + cols/rows (ceil(sqrt(N)))", "cols explicito define filas", "cell define dimension del canvas", "labels reservan franja bajo cada celda", "error: lista vacia", "error: ruta inexistente", "determinismo del dict de salida"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_grid.py"
file_path: "python/functions/ml/comfyui_build_grid.py"
---
@@ -57,7 +57,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
tested: false
tested: true
tests: ["estructura vae_encode (LoadImage+LoadImageMask+VAEEncodeForInpaint)", "prompt de region + grow_mask reflejados", "grow_mask se clampa a [0,64]", "mode noise_mask degrada a VAEEncode+SetLatentNoiseMask+GrowMask", "size inserta ImageScale a imagen y mascara + ImageToMask", "lora opcional + filename_prefix", "errores: input/mask/prompt vacios, mode invalido", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py"
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
---
@@ -55,7 +55,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
tested: false
tested: true
tests: ["estructura outpaint (ImagePadForOutpaint, sin LoadImageMask)", "pad cableado a VAEEncodeForInpaint (pixels<-IMAGE, mask<-MASK)", "extensiones redondeadas a multiplo de 8", "sin extension (todo 0 tras redondear) -> ValueError", "feather y prompt reflejados", "lora opcional + filename_prefix", "errores: input/prompt vacios", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py"
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
---
@@ -78,6 +78,21 @@ CheckpointLoaderSimple -> ... -> KSampler -> VAEDecode --IMAGE--+-> SaveImage (f
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
```
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from ml.comfyui_build_parallax_background_workflow import comfyui_build_parallax_background_workflow
# Fondo apaisado + su mapa de profundidad, 4 bandas de parallax (función pura, sin red).
wf = comfyui_build_parallax_background_workflow("forest at dusk, fantasy", layers=4, seed=7)
# El dict API format trae DOS SaveImage: el fondo y el depth map. Encólalo con submit_workflow.
saves = [n for n in wf.values() if n.get("class_type") == "SaveImage"]
print(len(saves), "SaveImage (fondo + depth)") # 2
```
## Cuando usarla
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
@@ -51,7 +51,9 @@ params:
- name: filename_prefix
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
tested: false
tested: true
tests: ["estructura txt2img + ControlNet (EmptyLatentImage, ControlNetLoader/Apply)", "lineart: preprocesador + modelo por defecto, ControlNetApply consume el mapa de lineas", "canny: preprocesador + modelo", "preprocess=False pasa el boceto directo al ControlNet", "controlnet_name override + strength reflejado", "strength se clampa a [0,2]", "lora opcional", "errores: sketch/subject vacios, control_type invalido", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py"
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
---
@@ -26,9 +26,9 @@ params:
- name: token
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["_extract_json: fence json", "_extract_json: brace plano", "_extract_json: sin objeto -> ValueError", "flujo: veredicto estructurado good", "verdict ambiguo -> bad conservador", "API caida -> ok=False", "respuesta no parseable -> ok=False"]
test_file_path: "python/functions/ml/tests/test_comfyui_critique_image_llm.py"
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
---
@@ -26,9 +26,9 @@ params:
- name: nsfw
desc: "Marca provenance.nsfw. keyword-only."
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["_slugify (normaliza y acota a 6 tokens)", "_loras_from_prompt", "_dims_from_prompt + _checkpoint_from_prompt", "_detect_base_workflow (flux/txt2img)", "_from_civitai_meta (mapea steps/cfg/size/modelo/prompts)", "flujo fallback a civitai_meta sin workflow embebido", "slug derivado del prompt", "error: sin workflow ni meta"]
test_file_path: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py"
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
---
@@ -26,9 +26,9 @@ params:
- name: resample
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["aplana transparente sobre blanco -> RGB sin alpha", "color de fondo personalizado", "size redimensiona a cuadrado", "out_path por defecto con sufijo _flat", "error: imagen inexistente", "determinismo (mismos bytes de salida)"]
test_file_path: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py"
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
---
@@ -22,9 +22,9 @@ params:
- 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: ""
tested: true
tests: ["API format se devuelve tal cual (format=api)", "UI graph se normaliza a API (descarta Note, resuelve conexiones)", "JSON invalido -> error", "formato no reconocido -> error", "JSON no es objeto -> error", "archivo inexistente -> error", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_import_workflow_json.py"
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
---
+3 -3
View File
@@ -32,9 +32,9 @@ params:
- name: venv_python
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["tres votos good -> verdict good + score medio", "mayoria bad", "empate -> bad conservador", "juez caido se excluye sin crashear", "los tres jueces fallan -> ok=False", "weights afectan score pero no el voto"]
test_file_path: "python/functions/ml/tests/test_comfyui_judge_image.py"
file_path: "python/functions/ml/comfyui_judge_image.py"
---
@@ -18,9 +18,9 @@ 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: ""
tested: true
tests: ["extrae prompt embebido + parametros del KSampler (seed/steps/cfg/sampler/scheduler/denoise/positive/negative/model)", "error: archivo inexistente", "error: PNG sin chunk prompt", "error: chunk prompt no es JSON", "error: no es un PNG valido", "determinismo"]
test_file_path: "python/functions/ml/tests/test_comfyui_read_png_metadata.py"
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
---
@@ -20,9 +20,9 @@ params:
- name: server
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["traduce nodos y modelos faltantes en suggestions (install_custom_node / search_and_download)", "sin faltantes -> suggestions vacio", "servidor caido -> ok=False con error propagado"]
test_file_path: "python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py"
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
---
@@ -26,9 +26,9 @@ params:
- name: timeout
desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only."
output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["error: imagen inexistente (guard previo al subproceso)", "error: python del venv ComfyUI ausente", "error: .pth del modelo ausente", "nunca lanza excepcion + determinismo del error"]
test_file_path: "python/functions/ml/tests/test_comfyui_score_aesthetic.py"
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
---
@@ -28,9 +28,9 @@ params:
- name: timeout
desc: "Timeout del subproceso en segundos. keyword-only."
output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion."
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["error: imagen inexistente", "error: prompt vacio", "error: python del venv ComfyUI ausente", "nunca lanza excepcion + determinismo del error"]
test_file_path: "python/functions/ml/tests/test_comfyui_score_clip_alignment.py"
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
---
@@ -0,0 +1,86 @@
"""Tests de estructura/determinismo para comfyui_build_asset_variant_workflow (func pura, img2img)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_asset_variant_workflow import comfyui_build_asset_variant_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def _texts(wf):
return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
def test_estructura_img2img():
# img2img: parte de una imagen (LoadImage + VAEEncode), NO de EmptyLatentImage.
wf = comfyui_build_asset_variant_workflow("enemy.png", "ice element")
assert_api_format(wf)
cts = class_types(wf)
for ct in ("CheckpointLoaderSimple", "LoadImage", "VAEEncode", "CLIPTextEncode",
"KSampler", "VAEDecode", "SaveImage"):
assert ct in cts, f"falta nodo {ct}"
assert "EmptyLatentImage" not in cts # img2img no genera desde ruido
def test_load_image_y_prompt_reflejados():
wf = comfyui_build_asset_variant_workflow(" enemy_creature_00001_.png ", "fire element")
# input_image se strippea y llega al LoadImage.
assert node_by_ct(wf, "LoadImage")["inputs"]["image"] == "enemy_creature_00001_.png"
# el positivo contiene la variante + el refuerzo de composicion.
pos = [t for t in _texts(wf) if "same composition" in t]
assert pos and "fire element" in pos[0]
def test_size_default_inserta_imagescale():
# size=512 por defecto -> normaliza la base con un ImageScale a 512x512.
wf = comfyui_build_asset_variant_workflow("enemy.png", "golden tier 2")
scale = node_by_ct(wf, "ImageScale")["inputs"]
assert scale["width"] == 512 and scale["height"] == 512
def test_size_none_sin_imagescale():
wf = comfyui_build_asset_variant_workflow("enemy.png", "frozen", size=None)
assert "ImageScale" not in class_types(wf)
def test_denoise_se_clampa():
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=2.0),
"KSampler")["inputs"]["denoise"] == 1.0
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=-1.0),
"KSampler")["inputs"]["denoise"] == 0.0
assert node_by_ct(comfyui_build_asset_variant_workflow("e.png", "v", denoise=0.5),
"KSampler")["inputs"]["denoise"] == 0.5
def test_filename_prefix_y_seed():
wf = comfyui_build_asset_variant_workflow("e.png", "v", seed=123, filename_prefix="mio")
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 123
def test_lora_inyecta_loraloader():
sin = comfyui_build_asset_variant_workflow("e.png", "v")
con = comfyui_build_asset_variant_workflow("e.png", "v", lora="SD15_dark.safetensors")
assert "LoraLoader" not in class_types(sin)
assert "LoraLoader" in class_types(con)
def test_input_image_vacio_lanza():
with pytest.raises(ValueError):
comfyui_build_asset_variant_workflow(" ", "v")
def test_variant_vacio_lanza():
with pytest.raises(ValueError):
comfyui_build_asset_variant_workflow("e.png", "")
def test_determinista():
a = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5)
b = comfyui_build_asset_variant_workflow("e.png", "ice", seed=7, denoise=0.5)
assert a == b
@@ -0,0 +1,83 @@
"""Tests de estructura/determinismo para comfyui_build_directional_sprite_workflow (func pura, 2.5D)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_directional_sprite_workflow import (
comfyui_build_directional_sprite_workflow,
directional_sprite_view_order,
)
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_sv3d_estructura_y_orbit_default():
wf = comfyui_build_directional_sprite_workflow("goblin.png", directions=8, model="sv3d")
assert_api_format(wf)
cts = class_types(wf)
for ct in ("LoadImage", "ImageOnlyCheckpointLoader", "SV3D_Conditioning",
"VideoLinearCFGGuidance", "KSampler", "VAEDecode", "SaveImage"):
assert ct in cts, f"falta nodo {ct}"
cond = node_by_ct(wf, "SV3D_Conditioning")["inputs"]
# video_frames default = directions; size nativa sv3d = 576.
assert cond["video_frames"] == 8
assert cond["width"] == 576 and cond["height"] == 576
def test_sv3d_orbit_frames_override():
wf = comfyui_build_directional_sprite_workflow("g.png", directions=8, orbit_frames=21)
assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["video_frames"] == 21
def test_zero123_estructura_y_azimuth():
wf = comfyui_build_directional_sprite_workflow("g.png", directions=4, model="zero123")
assert_api_format(wf)
cts = class_types(wf)
assert "StableZero123_Conditioning_Batched" in cts
assert "SV3D_Conditioning" not in cts # camino distinto al sv3d
cond = node_by_ct(wf, "StableZero123_Conditioning_Batched")["inputs"]
# batch = directions; size nativa zero123 = 256; azimuth equiespaciado 360/N.
assert cond["batch_size"] == 4
assert cond["width"] == 256 and cond["height"] == 256
assert cond["azimuth_batch_increment"] == 90.0
def test_cfg_y_ckpt_default_por_modelo():
sv3d = comfyui_build_directional_sprite_workflow("g.png", model="sv3d")
z123 = comfyui_build_directional_sprite_workflow("g.png", model="zero123")
assert node_by_ct(sv3d, "KSampler")["inputs"]["cfg"] == 2.5
assert node_by_ct(z123, "KSampler")["inputs"]["cfg"] == 4.0
assert node_by_ct(sv3d, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_sv3d_p.safetensors"
assert node_by_ct(z123, "ImageOnlyCheckpointLoader")["inputs"]["ckpt_name"] == "3D_stable_zero123.ckpt"
def test_elevation_y_seed_reflejados():
wf = comfyui_build_directional_sprite_workflow("g.png", model="sv3d", elevation=15.0, seed=42)
assert node_by_ct(wf, "SV3D_Conditioning")["inputs"]["elevation"] == 15.0
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 42
def test_view_order_helper():
assert directional_sprite_view_order(8) == ["S", "SE", "E", "NE", "N", "NW", "W", "SW"]
assert directional_sprite_view_order(4) == ["S", "E", "N", "W"]
# N no canonico -> etiquetas por azimuth.
assert directional_sprite_view_order(6) == ["az0", "az60", "az120", "az180", "az240", "az300"]
def test_errores():
with pytest.raises(ValueError):
comfyui_build_directional_sprite_workflow("")
with pytest.raises(ValueError):
comfyui_build_directional_sprite_workflow("g.png", model="turbo")
with pytest.raises(ValueError):
comfyui_build_directional_sprite_workflow("g.png", directions=0)
def test_determinista():
a = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0)
b = comfyui_build_directional_sprite_workflow("g.png", directions=8, seed=7, elevation=15.0)
assert a == b
@@ -0,0 +1,74 @@
"""Tests offline para comfyui_build_grid (impura PIL: lee N imagenes -> PNG grid).
Sin red, sin GPU, sin servidor: crea PNGs reales en un tmp_path y monta el grid.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_grid import comfyui_build_grid
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
def _png(path, size=(64, 64), color=(120, 30, 30)):
Image.new("RGB", size, color).save(path)
return str(path)
def test_grid_basico(tmp_path):
paths = [_png(tmp_path / f"i{i}.png") for i in range(4)]
out = tmp_path / "grid.png"
res = comfyui_build_grid(paths, out_path=str(out))
assert res["ok"] is True
assert res["error"] == ""
assert os.path.isfile(res["out_path"]) and res["out_path"] == str(out)
# 4 imagenes -> ceil(sqrt(4)) = 2 columnas, 2 filas.
assert res["cols"] == 2 and res["rows"] == 2
def test_cols_explicito_y_filas(tmp_path):
paths = [_png(tmp_path / f"i{i}.png") for i in range(5)]
res = comfyui_build_grid(paths, cols=5, out_path=str(tmp_path / "g.png"))
assert res["cols"] == 5 and res["rows"] == 1
def test_cell_define_dimension_del_canvas(tmp_path):
paths = [_png(tmp_path / f"i{i}.png") for i in range(2)]
res = comfyui_build_grid(paths, cols=2, cell=128, out_path=str(tmp_path / "g.png"))
with Image.open(res["out_path"]) as im:
# 2 columnas x 128 cell = 256 ancho; 1 fila x 128 = 128 alto.
assert im.size == (256, 128)
def test_labels_reservan_franja(tmp_path):
paths = [_png(tmp_path / f"i{i}.png") for i in range(2)]
res = comfyui_build_grid(paths, cols=2, cell=64, labels=["a", "b"],
out_path=str(tmp_path / "g.png"))
with Image.open(res["out_path"]) as im:
# con labels se reservan 22px bajo cada celda: alto = 64 + 22.
assert im.size == (128, 86)
def test_error_lista_vacia():
res = comfyui_build_grid([])
assert res["ok"] is False and "vacio" in res["error"]
def test_error_ruta_inexistente(tmp_path):
res = comfyui_build_grid([str(tmp_path / "no_existe.png")])
assert res["ok"] is False and "no existen" in res["error"]
def test_determinista_mismo_dict(tmp_path):
paths = [_png(tmp_path / f"i{i}.png") for i in range(3)]
a = comfyui_build_grid(paths, out_path=str(tmp_path / "a.png"))
b = comfyui_build_grid(paths, out_path=str(tmp_path / "b.png"))
# rows/cols/ok son determableinistas para las mismas entradas.
assert (a["ok"], a["rows"], a["cols"]) == (b["ok"], b["rows"], b["cols"])
@@ -0,0 +1,78 @@
"""Tests de estructura/determinismo para comfyui_build_inpaint_asset_workflow (func pura, inpaint)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_inpaint_asset_workflow import comfyui_build_inpaint_asset_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def _texts(wf):
return [n["inputs"].get("text", "") for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
def test_estructura_vae_encode():
wf = comfyui_build_inpaint_asset_workflow("asset.png", "mask.png", "a golden sword")
assert_api_format(wf)
cts = class_types(wf)
for ct in ("CheckpointLoaderSimple", "LoadImage", "LoadImageMask",
"VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"):
assert ct in cts, f"falta nodo {ct}"
def test_prompt_region_y_grow_mask():
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "blue shield", grow_mask=8)
pos = [t for t in _texts(wf) if "seamless blend" in t]
assert pos and "blue shield" in pos[0]
assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 8
def test_grow_mask_se_clampa():
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", grow_mask=999)
assert node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]["grow_mask_by"] == 64
def test_modo_noise_mask_degrada():
# noise_mask reemplaza VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask).
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="noise_mask", grow_mask=6)
cts = class_types(wf)
assert "VAEEncodeForInpaint" not in cts
assert "VAEEncode" in cts and "SetLatentNoiseMask" in cts and "GrowMask" in cts
def test_size_inserta_imagescale_a_imagen_y_mascara():
# size en modo vae_encode escala imagen Y mascara de forma consistente.
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", size=768)
scales = [n for n in wf.values() if n["class_type"] == "ImageScale"]
assert len(scales) == 2 # una para la imagen, otra para la mascara
assert all(s["inputs"]["width"] == 768 and s["inputs"]["height"] == 768 for s in scales)
assert "ImageToMask" in class_types(wf)
def test_lora_y_filename():
wf = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", lora="x.safetensors",
filename_prefix="mio")
assert "LoraLoader" in class_types(wf)
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
def test_errores():
with pytest.raises(ValueError):
comfyui_build_inpaint_asset_workflow("", "m.png", "p")
with pytest.raises(ValueError):
comfyui_build_inpaint_asset_workflow("a.png", "", "p")
with pytest.raises(ValueError):
comfyui_build_inpaint_asset_workflow("a.png", "m.png", "")
with pytest.raises(ValueError):
comfyui_build_inpaint_asset_workflow("a.png", "m.png", "p", mode="otro")
def test_determinista():
a = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6)
b = comfyui_build_inpaint_asset_workflow("a.png", "m.png", "orb", seed=7, grow_mask=6)
assert a == b
@@ -0,0 +1,73 @@
"""Tests de estructura/determinismo para comfyui_build_outpaint_asset_workflow (func pura, outpaint)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_outpaint_asset_workflow import comfyui_build_outpaint_asset_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_outpaint():
wf = comfyui_build_outpaint_asset_workflow("bg.png", "more forest", right=256)
assert_api_format(wf)
cts = class_types(wf)
for ct in ("CheckpointLoaderSimple", "LoadImage", "ImagePadForOutpaint",
"VAEEncodeForInpaint", "CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"):
assert ct in cts, f"falta nodo {ct}"
# outpaint genera su mascara con el pad: NO usa LoadImageMask.
assert "LoadImageMask" not in cts
def test_pad_cableado_a_vaeencode():
# VAEEncodeForInpaint toma pixels de la IMAGE del pad y mask de la MASK del pad.
wf = comfyui_build_outpaint_asset_workflow("bg.png", "sky", top=128)
pad_id = next(nid for nid, n in wf.items() if n["class_type"] == "ImagePadForOutpaint")
enc = node_by_ct(wf, "VAEEncodeForInpaint")["inputs"]
assert enc["pixels"] == [pad_id, 0]
assert enc["mask"] == [pad_id, 1]
def test_extensiones_redondeadas_a_8():
# _round8 normaliza al multiplo de 8 mas cercano.
wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=10)
pad = node_by_ct(wf, "ImagePadForOutpaint")["inputs"]
assert pad["right"] == 8 and pad["left"] == 0 and pad["top"] == 0 and pad["bottom"] == 0
def test_sin_extension_lanza():
# las cuatro extensiones a 0 (tras redondear) -> no hay nada que extender.
with pytest.raises(ValueError):
comfyui_build_outpaint_asset_workflow("bg.png", "p", left=3, right=2)
def test_feather_y_prompt():
wf = comfyui_build_outpaint_asset_workflow("bg.png", "open sky", top=64, feather=30)
assert node_by_ct(wf, "ImagePadForOutpaint")["inputs"]["feathering"] == 30
pos = [n["inputs"]["text"] for n in wf.values()
if n["class_type"] == "CLIPTextEncode" and "seamless extension" in n["inputs"].get("text", "")]
assert pos and "open sky" in pos[0]
def test_lora_y_filename():
wf = comfyui_build_outpaint_asset_workflow("bg.png", "p", right=64, lora="x.safetensors",
filename_prefix="mio")
assert "LoraLoader" in class_types(wf)
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "mio"
def test_errores_vacios():
with pytest.raises(ValueError):
comfyui_build_outpaint_asset_workflow("", "p", right=64)
with pytest.raises(ValueError):
comfyui_build_outpaint_asset_workflow("bg.png", "", right=64)
def test_determinista():
a = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7)
b = comfyui_build_outpaint_asset_workflow("bg.png", "forest", right=256, seed=7)
assert a == b
@@ -0,0 +1,80 @@
"""Tests de estructura/determinismo para comfyui_build_sprite_from_sketch_workflow (func pura, ControlNet)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_build_sprite_from_sketch_workflow import comfyui_build_sprite_from_sketch_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_txt2img_mas_controlnet():
# txt2img (EmptyLatentImage, denoise alto) guiado por ControlNet atado al boceto.
wf = comfyui_build_sprite_from_sketch_workflow("sketch.png", "armored knight")
assert_api_format(wf)
cts = class_types(wf)
for ct in ("CheckpointLoaderSimple", "EmptyLatentImage", "CLIPTextEncode", "KSampler",
"VAEDecode", "SaveImage", "LoadImage", "ControlNetLoader", "ControlNetApply"):
assert ct in cts, f"falta nodo {ct}"
def test_lineart_default_preprocesador_y_modelo():
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", control_type="lineart")
assert "LineArtPreprocessor" in class_types(wf)
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
"control_v11p_sd15_lineart_fp16.safetensors"
# el ControlNetApply consume el mapa de lineas del preprocesador, no el LoadImage directo.
pre_id = next(nid for nid, n in wf.items() if n["class_type"].endswith("Preprocessor"))
assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [pre_id, 0]
def test_canny_preprocesador_y_modelo():
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "chest", control_type="canny")
assert "CannyEdgePreprocessor" in class_types(wf)
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
"control_v11p_sd15_canny_fp16.safetensors"
def test_preprocess_false_pasa_boceto_directo():
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", preprocess=False)
assert not any(n["class_type"].endswith("Preprocessor") for n in wf.values())
load_id = next(nid for nid, n in wf.items() if n["class_type"] == "LoadImage")
assert node_by_ct(wf, "ControlNetApply")["inputs"]["image"] == [load_id, 0]
def test_controlnet_name_override_y_strength():
wf = comfyui_build_sprite_from_sketch_workflow(
"s.png", "k", control_type="lineart",
controlnet_name="control_v11p_sd15_canny_fp16.safetensors", strength=0.65)
assert node_by_ct(wf, "ControlNetLoader")["inputs"]["control_net_name"] == \
"control_v11p_sd15_canny_fp16.safetensors"
assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 0.65
def test_strength_se_clampa():
wf = comfyui_build_sprite_from_sketch_workflow("s.png", "k", strength=5.0)
assert node_by_ct(wf, "ControlNetApply")["inputs"]["strength"] == 2.0
def test_lora_inyecta():
assert "LoraLoader" in class_types(
comfyui_build_sprite_from_sketch_workflow("s.png", "k", lora="x.safetensors"))
def test_errores():
with pytest.raises(ValueError):
comfyui_build_sprite_from_sketch_workflow("", "k")
with pytest.raises(ValueError):
comfyui_build_sprite_from_sketch_workflow("s.png", "")
with pytest.raises(ValueError):
comfyui_build_sprite_from_sketch_workflow("s.png", "k", control_type="depth")
def test_determinista():
a = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8)
b = comfyui_build_sprite_from_sketch_workflow("s.png", "knight", seed=7, strength=0.8)
assert a == b
@@ -0,0 +1,62 @@
"""Tests offline para comfyui_critique_image_llm (impura: critica LLM-vision via ask_llm_vision).
Sin red, sin API: prueba el parser de JSON puro (_extract_json) y el flujo con ask_llm_vision
monkeypatcheado (veredicto estructurado, ambiguo->bad conservador, API caida, texto no parseable).
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import ml.comfyui_critique_image_llm as mod
from ml.comfyui_critique_image_llm import comfyui_critique_image_llm, _extract_json
def test_extract_json_fenced():
txt = 'blah\n```json\n{"verdict": "good", "score": 8}\n```\nfin'
assert _extract_json(txt) == {"verdict": "good", "score": 8}
def test_extract_json_brace_plano():
assert _extract_json(' {"verdict": "bad", "score": 2} ') == {"verdict": "bad", "score": 2}
def test_extract_json_sin_objeto_lanza():
with pytest.raises(ValueError):
_extract_json("no hay json aqui")
def _fake_vision(text, ok=True):
return lambda user_prompt, image_path, **kw: {"ok": ok, "text": text, "error": "" if ok else "429"}
def test_flujo_veredicto_estructurado(monkeypatch):
monkeypatch.setattr(mod, "ask_llm_vision",
_fake_vision('{"verdict": "good", "score": 8.5, "reasons": ["nitida"]}'))
res = comfyui_critique_image_llm("i.png", "a cat")
assert res["ok"] is True
assert res["verdict"] == "good" and res["score_0_10"] == 8.5
assert res["reasons"] == ["nitida"]
def test_verdict_ambiguo_cae_a_bad(monkeypatch):
monkeypatch.setattr(mod, "ask_llm_vision",
_fake_vision('{"verdict": "maybe", "score": 5}'))
res = comfyui_critique_image_llm("i.png", "p")
assert res["ok"] is True and res["verdict"] == "bad" # conservador ante ambiguo
def test_api_caida_ok_false(monkeypatch):
monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("", ok=False))
res = comfyui_critique_image_llm("i.png", "p")
assert res["ok"] is False and res["error"]
def test_respuesta_no_parseable_ok_false(monkeypatch):
monkeypatch.setattr(mod, "ask_llm_vision", _fake_vision("lo siento, no puedo"))
res = comfyui_critique_image_llm("i.png", "p")
assert res["ok"] is False and "no parseable" in res["error"]
@@ -0,0 +1,86 @@
"""Tests offline para comfyui_extract_recipe_from_png (impura: destila PNG -> receta de skill).
Sin red, sin servidor: prueba los helpers puros de extraccion y el flujo de degradacion a la
`meta` de Civitai cuando el PNG no trae workflow embebido (PNG inexistente -> sin workflow).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_extract_recipe_from_png import (
comfyui_extract_recipe_from_png,
_slugify,
_loras_from_prompt,
_dims_from_prompt,
_checkpoint_from_prompt,
_detect_base_workflow,
_from_civitai_meta,
)
def test_slugify():
assert _slugify("A Red Apple!", "fb") == "a_red_apple"
assert _slugify("", "fallback") == "fallback"
# acota a 6 tokens.
assert _slugify("one two three four five six seven eight", "fb").count("_") == 5
def test_loras_from_prompt():
prompt = {"7": {"class_type": "LoraLoader",
"inputs": {"lora_name": "style.safetensors",
"strength_model": 0.8, "strength_clip": 0.7}}}
loras = _loras_from_prompt(prompt)
assert loras == [{"name": "style.safetensors", "strength_model": 0.8, "strength_clip": 0.7}]
assert _loras_from_prompt({}) == []
def test_dims_y_checkpoint_from_prompt():
prompt = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "dream.safetensors"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216}},
}
assert _dims_from_prompt(prompt) == {"width": 832, "height": 1216}
assert _checkpoint_from_prompt(prompt) == "dream.safetensors"
def test_detect_base_workflow():
assert _detect_base_workflow({"1": {"class_type": "UNETLoader", "inputs": {}}}) == "flux"
assert _detect_base_workflow({"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}) == "txt2img"
def test_from_civitai_meta():
meta = {"steps": 25, "sampler": "Euler a", "Size": "832x1216", "seed": 7,
"cfgScale": 6.5, "Model": "mymodel", "prompt": "a cat", "negativePrompt": "blurry"}
out = _from_civitai_meta(meta)
assert out["checkpoint"] == "mymodel"
assert out["positive"] == "a cat" and out["negative"] == "blurry"
assert out["params"]["steps"] == 25 and out["params"]["cfg"] == 6.5
assert out["params"]["width"] == 832 and out["params"]["height"] == 1216
def test_flujo_fallback_civitai_meta(tmp_path):
# PNG inexistente -> sin workflow embebido; cae a la meta de Civitai (utilizable).
res = comfyui_extract_recipe_from_png(
str(tmp_path / "no.png"),
civitai_meta={"prompt": "a knight", "Model": "dream.safetensors", "steps": 20})
assert res["ok"] is True
assert res["has_workflow"] is False
recipe = res["recipe"]
assert recipe["checkpoint"] == "dream.safetensors"
assert recipe["prompt_scaffold"]["positive"] == "a knight"
assert recipe["provenance"]["source"] == "civitai" and recipe["score_n"] == 0
def test_slug_derivado_del_prompt(tmp_path):
res = comfyui_extract_recipe_from_png(
str(tmp_path / "no.png"), civitai_meta={"prompt": "Fire Goblin Warrior"})
assert res["ok"] is True and res["slug"] == "fire_goblin_warrior"
def test_error_sin_workflow_ni_meta(tmp_path):
res = comfyui_extract_recipe_from_png(str(tmp_path / "no.png"))
assert res["ok"] is False and res["recipe"] == {}
assert "no trae workflow" in res["error"]
@@ -0,0 +1,68 @@
"""Tests offline para comfyui_flatten_alpha_on_color (impura PIL: aplana RGBA sobre fondo solido).
Sin red, sin GPU, sin servidor: crea un PNG RGBA real y verifica el RGB resultante.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_flatten_alpha_on_color import comfyui_flatten_alpha_on_color
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
def _rgba(path, size=(32, 32), color=(0, 0, 0, 0)):
Image.new("RGBA", size, color).save(path)
return str(path)
def test_aplana_transparente_sobre_blanco(tmp_path):
src = _rgba(tmp_path / "sprite.png", color=(0, 0, 0, 0)) # totalmente transparente
out = tmp_path / "flat.png"
res = comfyui_flatten_alpha_on_color(src, out_path=str(out), color=(255, 255, 255))
assert res["ok"] is True and res["error"] == ""
with Image.open(res["out_path"]) as im:
assert im.mode == "RGB" # sin alpha
# sobre alpha 0 queda el fondo solido: blanco.
assert im.getpixel((0, 0)) == (255, 255, 255)
def test_color_de_fondo_personalizado(tmp_path):
src = _rgba(tmp_path / "s.png", color=(0, 0, 0, 0))
res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), color=(10, 20, 30))
with Image.open(res["out_path"]) as im:
assert im.getpixel((0, 0)) == (10, 20, 30)
def test_size_redimensiona_cuadrado(tmp_path):
src = _rgba(tmp_path / "s.png", size=(32, 16))
res = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "o.png"), size=64)
assert res["size"] == [64, 64]
with Image.open(res["out_path"]) as im:
assert im.size == (64, 64)
def test_out_path_default_sufijo_flat(tmp_path):
src = _rgba(tmp_path / "sprite.png")
res = comfyui_flatten_alpha_on_color(src) # out_path None -> <base>_flat.png
assert res["ok"] is True
assert res["out_path"].endswith("sprite_flat.png")
def test_error_imagen_inexistente(tmp_path):
res = comfyui_flatten_alpha_on_color(str(tmp_path / "no.png"))
assert res["ok"] is False and "no existe" in res["error"]
def test_determinista(tmp_path):
src = _rgba(tmp_path / "s.png", color=(5, 5, 5, 128))
a = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "a.png"), color=(200, 0, 0))
b = comfyui_flatten_alpha_on_color(src, out_path=str(tmp_path / "b.png"), color=(200, 0, 0))
with Image.open(a["out_path"]) as ia, Image.open(b["out_path"]) as ib:
assert ia.tobytes() == ib.tobytes()
@@ -0,0 +1,88 @@
"""Tests offline para comfyui_import_workflow_json (impura: lee disco/URL + normaliza a API format).
Sin red, sin servidor: lee workflows desde archivos locales. Para el caso UI graph monkeypatchea
comfyui_object_info (devuelve None) para no consultar el servidor; se valida la resolucion de
conexiones y el descarte de nodos virtuales (Note).
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import ml.comfyui_import_workflow_json as mod
from ml.comfyui_import_workflow_json import comfyui_import_workflow_json
from _comfyui_wf_assert import assert_api_format, class_types
_API = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m.safetensors"}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0], "vae": ["1", 2]}},
}
_UI_GRAPH = {
"nodes": [
{"id": 1, "type": "CheckpointLoaderSimple", "inputs": [], "widgets_values": ["m.safetensors"]},
{"id": 2, "type": "Note", "inputs": []},
{"id": 3, "type": "VAEDecode",
"inputs": [{"name": "samples", "link": 10}, {"name": "vae", "link": 11}]},
],
"links": [
[10, 1, 0, 3, 0, "LATENT"],
[11, 1, 2, 3, 1, "VAE"],
],
}
def _write(tmp_path, name, obj):
p = tmp_path / name
p.write_text(json.dumps(obj))
return str(p)
def test_api_format_se_devuelve_tal_cual(tmp_path):
res = comfyui_import_workflow_json(_write(tmp_path, "api.json", _API))
assert res["ok"] is True and res["format_detected"] == "api"
assert res["workflow"] == _API
def test_ui_graph_se_normaliza(tmp_path, monkeypatch):
monkeypatch.setattr(mod, "comfyui_object_info", lambda server="", timeout=5.0: None)
res = comfyui_import_workflow_json(_write(tmp_path, "ui.json", _UI_GRAPH))
assert res["ok"] is True and res["format_detected"] == "ui_graph"
api = res["workflow"]
assert_api_format(api)
# el nodo virtual Note se descarta; las conexiones del VAEDecode se resuelven al CheckpointLoader.
assert "Note" not in class_types(api)
assert "2" not in api
assert api["3"]["inputs"]["samples"] == ["1", 0]
assert api["3"]["inputs"]["vae"] == ["1", 2]
def test_json_invalido_error(tmp_path):
p = tmp_path / "bad.json"
p.write_text("no soy json {")
res = comfyui_import_workflow_json(str(p))
assert res["ok"] is False and "JSON invalido" in res["error"]
def test_formato_no_reconocido(tmp_path):
res = comfyui_import_workflow_json(_write(tmp_path, "x.json", {"foo": "bar"}))
assert res["ok"] is False and "no reconocido" in res["error"]
def test_json_no_es_objeto(tmp_path):
res = comfyui_import_workflow_json(_write(tmp_path, "lst.json", [1, 2, 3]))
assert res["ok"] is False and "no es un objeto de workflow" in res["error"]
def test_archivo_inexistente(tmp_path):
res = comfyui_import_workflow_json(str(tmp_path / "no.json"))
assert res["ok"] is False and "no se pudo leer" in res["error"]
def test_determinista(tmp_path):
path = _write(tmp_path, "api.json", _API)
assert comfyui_import_workflow_json(path) == comfyui_import_workflow_json(path)
@@ -0,0 +1,82 @@
"""Tests offline para comfyui_judge_image (impura: panel multi-juez por mayoria).
Sin GPU, sin red, sin servidor: monkeypatchea los tres jueces (estetico, fidelidad CLIP,
critico LLM) con stubs para probar la LOGICA de voto, agregacion y exclusion de jueces caidos.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import ml.comfyui_judge_image as mod
from ml.comfyui_judge_image import comfyui_judge_image
def _aes(score, ok=True):
return lambda image_path, **kw: {"ok": ok, "score_0_10": score, "error": "" if ok else "boom"}
def _clip(score, ok=True):
return lambda image_path, prompt, **kw: {"ok": ok, "score_0_1": score, "error": "" if ok else "boom"}
def _llm(verdict, score=7.0, ok=True):
return lambda image_path, prompt, **kw: {
"ok": ok, "verdict": verdict, "score_0_10": score,
"reasons": ["motivo"], "error": "" if ok else "boom"}
def _patch(monkeypatch, aes, clip, llm):
monkeypatch.setattr(mod, "comfyui_score_aesthetic", aes)
monkeypatch.setattr(mod, "comfyui_score_clip_alignment", clip)
monkeypatch.setattr(mod, "comfyui_critique_image_llm", llm)
def test_tres_good_verdict_good(monkeypatch):
_patch(monkeypatch, _aes(8.0), _clip(0.30), _llm("good"))
res = comfyui_judge_image("i.png", "a cat")
assert res["ok"] is True and res["verdict"] == "good"
assert res["votes"] == {"aesthetic": "good", "clip": "good", "llm": "good"}
# score = media de 8, 3.0(=0.30*10), 7 = 6.0
assert abs(res["score"] - 6.0) < 1e-9
def test_mayoria_bad(monkeypatch):
# estetico bajo (bad) + clip bajo (bad) + llm good -> 2 bad, 1 good -> bad.
_patch(monkeypatch, _aes(2.0), _clip(0.05), _llm("good"))
res = comfyui_judge_image("i.png", "p")
assert res["verdict"] == "bad"
def test_empate_es_bad_conservador(monkeypatch):
# 1 good (estetico) + 1 bad (clip) + 1 failed (llm) -> empate -> bad.
_patch(monkeypatch, _aes(8.0), _clip(0.05), _llm("good", ok=False))
res = comfyui_judge_image("i.png", "p")
assert res["votes"]["llm"] == "failed"
assert res["verdict"] == "bad"
def test_juez_caido_se_excluye_no_crashea(monkeypatch):
# estetico falla pero el panel sigue votando con los otros dos.
_patch(monkeypatch, _aes(0.0, ok=False), _clip(0.30), _llm("good"))
res = comfyui_judge_image("i.png", "p")
assert res["ok"] is True
assert res["votes"]["aesthetic"] == "failed"
assert res["verdict"] == "good"
def test_tres_fallan_ok_false(monkeypatch):
_patch(monkeypatch, _aes(0.0, ok=False), _clip(0.0, ok=False), _llm("", ok=False))
res = comfyui_judge_image("i.png", "p")
assert res["ok"] is False and "los tres jueces fallaron" in res["error"]
def test_weights_afectan_score_no_voto(monkeypatch):
_patch(monkeypatch, _aes(10.0), _clip(0.30), _llm("good", score=0.0))
base = comfyui_judge_image("i.png", "p")
# subir el peso del estetico (10) y anular el del llm (0) sube el score agregado.
weighted = comfyui_judge_image("i.png", "p", weights={"aesthetic": 5.0, "llm": 0.0})
assert weighted["score"] > base["score"]
assert weighted["verdict"] == base["verdict"] == "good"
@@ -0,0 +1,80 @@
"""Tests offline para comfyui_read_png_metadata (impura stdlib: parsea metadata de un PNG ComfyUI).
Sin red, sin GPU, sin servidor: fabrica PNGs con chunk de texto 'prompt' y verifica el parsing.
"""
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_read_png_metadata import comfyui_read_png_metadata
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
from PIL.PngImagePlugin import PngInfo # noqa: E402
_PROMPT = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "model.safetensors"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat on a table"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry, lowres"}},
"4": {"class_type": "KSampler", "inputs": {
"seed": 42, "steps": 20, "cfg": 7.0, "sampler_name": "euler",
"scheduler": "normal", "denoise": 1.0,
"positive": ["2", 0], "negative": ["3", 0], "model": ["1", 0], "latent_image": ["5", 0]}},
}
def _png_with_prompt(path, prompt_obj=_PROMPT, text=None):
info = PngInfo()
info.add_text("prompt", text if text is not None else json.dumps(prompt_obj))
Image.new("RGB", (8, 8), (0, 0, 0)).save(path, pnginfo=info)
return str(path)
def _png_plain(path):
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
return str(path)
def test_extrae_prompt_y_parametros(tmp_path):
res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "g.png"))
assert res["ok"] is True and res["error"] == ""
assert res["prompt"] == _PROMPT
p = res["parameters"]
assert p["seed"] == 42 and p["steps"] == 20 and p["cfg"] == 7.0
assert p["sampler_name"] == "euler" and p["scheduler"] == "normal" and p["denoise"] == 1.0
assert p["positive"] == "a cat on a table" and p["negative"] == "blurry, lowres"
assert p["model"] == "model.safetensors"
def test_error_archivo_inexistente(tmp_path):
res = comfyui_read_png_metadata(str(tmp_path / "no.png"))
assert res["ok"] is False and "no se pudo leer" in res["error"]
def test_error_png_sin_chunk_prompt(tmp_path):
res = comfyui_read_png_metadata(_png_plain(tmp_path / "plain.png"))
assert res["ok"] is False and "no contiene chunk 'prompt'" in res["error"]
def test_error_prompt_no_json(tmp_path):
res = comfyui_read_png_metadata(_png_with_prompt(tmp_path / "bad.png", text="no soy json {"))
assert res["ok"] is False and "no es JSON valido" in res["error"]
def test_error_no_es_png(tmp_path):
bad = tmp_path / "fake.png"
bad.write_bytes(b"esto no es un PNG")
res = comfyui_read_png_metadata(str(bad))
assert res["ok"] is False and res["error"]
def test_determinista(tmp_path):
path = _png_with_prompt(tmp_path / "g.png")
assert comfyui_read_png_metadata(path) == comfyui_read_png_metadata(path)
@@ -0,0 +1,49 @@
"""Tests offline para comfyui_resolve_workflow_deps (impura: compone comfyui_validate_workflow).
Sin red, sin servidor: monkeypatchea comfyui_validate_workflow para probar la traduccion de
nodos/modelos faltantes en sugerencias accionables y el error path cuando el servidor no responde.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import ml.comfyui_resolve_workflow_deps as mod
from ml.comfyui_resolve_workflow_deps import comfyui_resolve_workflow_deps
_WF = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x.safetensors"}}}
def test_traduce_nodos_y_modelos_faltantes(monkeypatch):
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
"ok": True,
"missing_nodes": ["FooNode"],
"missing_models": [{"node": "1", "input": "ckpt_name", "value": "x.safetensors"}],
})
res = comfyui_resolve_workflow_deps(_WF)
assert res["ok"] is True and res["error"] == ""
assert res["missing_nodes"] == ["FooNode"]
kinds = {s["kind"] for s in res["suggestions"]}
assert kinds == {"node", "model"}
node_sug = next(s for s in res["suggestions"] if s["kind"] == "node")
assert node_sug["action"] == "install_custom_node" and node_sug["name"] == "FooNode"
model_sug = next(s for s in res["suggestions"] if s["kind"] == "model")
assert model_sug["action"] == "search_and_download" and model_sug["name"] == "x.safetensors"
def test_sin_faltantes_suggestions_vacio(monkeypatch):
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
"ok": True, "missing_nodes": [], "missing_models": []})
res = comfyui_resolve_workflow_deps(_WF)
assert res["ok"] is True and res["suggestions"] == []
def test_servidor_caido_propaga_error(monkeypatch):
monkeypatch.setattr(mod, "comfyui_validate_workflow", lambda wf, server="": {
"ok": False, "error": "no se pudo conectar al servidor"})
res = comfyui_resolve_workflow_deps(_WF)
assert res["ok"] is False
assert "no se pudo conectar" in res["error"]
assert res["suggestions"] == []
@@ -0,0 +1,51 @@
"""Tests offline para comfyui_score_aesthetic (impura: scoring LAION-V2 via subproceso torch).
Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen,
python del venv ComfyUI y .pth del modelo ausentes), que cortan antes de tocar la GPU.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_score_aesthetic import comfyui_score_aesthetic
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
def _png(path):
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
return str(path)
def test_error_imagen_inexistente(tmp_path):
res = comfyui_score_aesthetic(str(tmp_path / "no.png"))
assert res["ok"] is False and res["score_0_10"] == 0.0
assert "imagen no encontrada" in res["error"]
def test_error_venv_python_inexistente(tmp_path):
# imagen valida pero venv_python ausente -> corta antes del subproceso.
res = comfyui_score_aesthetic(_png(tmp_path / "i.png"),
venv_python=str(tmp_path / "no_python"))
assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"]
def test_error_modelo_inexistente(tmp_path):
# imagen + python validos, .pth ausente -> error de modelo, sin lanzar el subproceso.
res = comfyui_score_aesthetic(_png(tmp_path / "i.png"),
venv_python=sys.executable,
model_path=str(tmp_path / "no.pth"))
assert res["ok"] is False and "modelo estetico no encontrado" in res["error"]
def test_nunca_lanza_y_es_determinista(tmp_path):
img = _png(tmp_path / "i.png")
a = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x"))
b = comfyui_score_aesthetic(img, venv_python=str(tmp_path / "x"))
assert a == b and a["ok"] is False
@@ -0,0 +1,47 @@
"""Tests offline para comfyui_score_clip_alignment (impura: similitud CLIP via subproceso torch).
Sin GPU, sin torch, sin servidor: ejercita SOLO los guards previos al subproceso (imagen
ausente, prompt vacio, python del venv ComfyUI ausente).
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_score_clip_alignment import comfyui_score_clip_alignment
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
def _png(path):
Image.new("RGB", (8, 8), (0, 0, 0)).save(path)
return str(path)
def test_error_imagen_inexistente(tmp_path):
res = comfyui_score_clip_alignment(str(tmp_path / "no.png"), "a cat")
assert res["ok"] is False and res["score_0_1"] == 0.0
assert "imagen no encontrada" in res["error"]
def test_error_prompt_vacio(tmp_path):
res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), " ")
assert res["ok"] is False and "prompt vacio" in res["error"]
def test_error_venv_python_inexistente(tmp_path):
res = comfyui_score_clip_alignment(_png(tmp_path / "i.png"), "a cat",
venv_python=str(tmp_path / "no_python"))
assert res["ok"] is False and "python del venv ComfyUI no encontrado" in res["error"]
def test_nunca_lanza_y_es_determinista(tmp_path):
img = _png(tmp_path / "i.png")
a = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x"))
b = comfyui_score_clip_alignment(img, "a cat", venv_python=str(tmp_path / "x"))
assert a == b and a["ok"] is False
@@ -0,0 +1,131 @@
---
name: comfyui_generate_until_quality
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "comfyui_generate_until_quality(builder, subject, *, threshold=6.0, clip_threshold=0.24, max_iters=4, strategy='reroll+escalate+refine_prompt', server='127.0.0.1:8188', dest_dir='~/ComfyUI/output', judge_prompt=None, seed=0, refine_model='claude-haiku-4-5-20251001', judge_model='claude-opus-4-8', wait_timeout=300.0, **builder_kwargs) -> dict"
description: "Loop evaluator-optimizer (GAN sin entrenar): genera una imagen con un builder del registry, la juzga con el panel multi-juez, y si no alcanza la calidad pedida refina (nueva seed, mas calidad, prompt corregido con el feedback del juez) y regenera hasta pasar el umbral o agotar intentos. Siempre devuelve la mejor candidata por score (best-of-N)."
tags: [comfyui, comfyui-skill, pipeline, launcher, generate, judge, quality-loop, evaluator-optimizer]
uses_functions:
- comfyui_submit_workflow_py_ml
- comfyui_wait_result_py_ml
- comfyui_fetch_output_image_py_ml
- comfyui_judge_image_py_ml
- ask_llm_py_core
uses_types: []
returns: []
returns_optional: false
error_type: error_py_core
imports: [comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, comfyui_judge_image_py_ml, ask_llm_py_core]
params:
- name: builder
desc: "Callable o nombre (str) de un builder comfyui_build_*_workflow del registry. El subject se pasa como primer positional (builders de asset: ui_hud, item_icon, enemy_creature...)."
- name: subject
desc: "Descripcion del elemento a generar (p.ej. 'RPG health and mana bars'). Se inyecta en el builder y, si se refina, se reescribe con el feedback del juez."
- name: threshold
desc: "Umbral estetico 0-10 que el juez usa para votar good/bad."
- name: clip_threshold
desc: "Umbral de fidelidad CLIP 0-1 del juez (prompt<->imagen)."
- name: max_iters
desc: "Numero maximo de iteraciones de generacion."
- name: strategy
desc: "Tacticas de mejora separadas por '+': reroll (seed nueva), escalate (mas steps/cfg en iters tardias), refine_prompt (reescribe el subject con ask_llm usando las razones del juez)."
- name: server
desc: "host:port del servidor ComfyUI sin esquema."
- name: dest_dir
desc: "Directorio local donde guardar los PNG."
- name: judge_prompt
desc: "Texto que se pasa al juez para medir fidelidad. None = se extrae el positive del workflow construido."
- name: seed
desc: "Semilla base; los rerolls derivan de ella de forma determinista."
- name: refine_model
desc: "Modelo de ask_llm para el refine del prompt (barato, haiku por defecto)."
- name: judge_model
desc: "Modelo del juez critico LLM-vision."
- name: wait_timeout
desc: "Segundos maximos esperando cada generacion."
- name: builder_kwargs
desc: "Parametros extra del builder (ui_style, checkpoint, size, transparent...). Solo se pasan los que el builder acepta (filtrados por inspect.signature)."
output: "dict {ok, converged, best_image_path, best_score, best_verdict, iterations, error}. iterations = lista de {iter, seed, params, score, verdict, reasons, image, error}. converged=True si alguna iteracion logro verdict 'good'. best_* apuntan a la mejor candidata por score aunque ninguna convergiera."
file_path: "python/functions/pipelines/comfyui_generate_until_quality.py"
tested: false
tests: []
test_file_path: ""
---
# comfyui_generate_until_quality
Loop **evaluator-optimizer** sobre ComfyUI: el patrón de una GAN (generador vs.
discriminador) pero **sin entrenar nada**. Un builder genera una imagen, el panel
multi-juez (`comfyui_judge_image`) la puntúa, y si no llega al umbral el pipeline
**refina** (nueva seed, más calidad, prompt corregido con las quejas del juez) y
regenera, hasta converger (`verdict == 'good'`) o agotar `max_iters`. Devuelve
**siempre la mejor candidata por score** (best-of-N): nunca basura por agotar
intentos.
Es la promoción a pipeline one-shot (issue 0087) del bucle de mejora del grupo
`comfyui-skill`: build → submit → wait → fetch → judge → (refine) → repeat.
## Ejemplo
```python
import sys, json
sys.path.insert(0, "python/functions")
from pipelines.comfyui_generate_until_quality import comfyui_generate_until_quality
res = comfyui_generate_until_quality(
"comfyui_build_ui_hud_workflow", # builder por nombre
"RPG health and mana bars, clean game UI", # subject
ui_style="fantasy game UI, clean vector, high contrast, sharp edges",
threshold=6.5, max_iters=3,
dest_dir="/tmp/comfy_until_quality", transparent=False, seed=1000,
)
print(res["converged"], round(res["best_score"], 2), res["best_verdict"])
print("scores:", [it["score"] for it in res["iterations"]]) # historial subiendo
print("mejor imagen:", res["best_image_path"])
```
```bash
# Lanzar directo (caso HUD del ejemplo __main__)
~/fn_registry/python/.venv/bin/python3 \
python/functions/pipelines/comfyui_generate_until_quality.py
```
## Cuando usarla
- Cuando pides un asset (HUD, icono, sprite) y la primera generación sale
borrosa/floja y quieres que el sistema **itere solo** hasta una versión usable,
en vez de re-tirar seeds a mano.
- Cuando quieres un **gate de calidad objetivo** que devuelva lo mejor de N
intentos rankeado por el panel multi-juez, no la primera que salga.
- Como bloque del bucle reactivo del grupo `comfyui-skill`: un skill no está
"hecho" hasta que su imagen pasa el panel; este pipeline es ese bucle.
## Gotchas
- **Impuro**: red (HTTP a ComfyUI), GPU (generación), disco (PNG), API
(juez crítico LLM + refine de prompt). Necesita ComfyUI vivo en `server` y el
venv de jueces (`~/ComfyUI/.venv`, ver `comfyui-judge`).
- **El `subject` se pasa como PRIMER positional del builder**. Vale para los
builders de asset (`comfyui_build_ui_hud_workflow`, `_item_icon_`,
`_enemy_creature_`...), cuyo primer arg es el elemento. NO para
`comfyui_build_txt2img_workflow` (primer arg = `ckpt`): para texto crudo, envuélvelo
o pasa un builder de asset.
- **Filtra kwargs con `inspect.signature`**: solo pasa al builder los que acepta,
así `escalate` (sube `steps`/`cfg`) y `reroll` (set `seed`) no rompen entre
builders con firmas distintas. Si un builder no expone `steps`/`seed`, esa
táctica simplemente no aplica en él.
- **`escalate` sube `steps`+`cfg`**, no inyecta hires-fix (no todos los builders
lo soportan y ui_hud lleva Rembg). Para upscale dedicado, usar
`comfyui_build_hires_fix_workflow` como builder.
- **Degrada con gracia**: si el juez cae (HTTP 429) la imagen se conserva con
score 0/verdict 'unknown' y el loop sigue; si una iteración falla en
submit/wait/fetch se registra su `error` y se reintenta la siguiente. Solo
devuelve `ok=False` si NINGUNA iteración produjo imagen.
- **VRAM (8GB)**: entre familias de generación, liberar con
`POST /free {"unload_models":true,"free_memory":true}` si el juez estético
(CLIP+LAION en el venv ComfyUI) compite por VRAM con el checkpoint SD.
- **Determinista en estructura**: nunca lanza excepción cruda; siempre dict de
estado. El refine usa `ask_llm` (best-effort): si falla, mantiene el subject.
@@ -0,0 +1,349 @@
"""comfyui_generate_until_quality — loop evaluator-optimizer (GAN sin entrenar).
Genera una imagen con un builder del registry, la juzga con el panel multi-juez
(`comfyui_judge_image`), y si no alcanza la calidad pedida REFINA (nueva seed,
mas calidad, prompt corregido con el feedback del juez) y regenera, hasta que
pasa el umbral (`verdict == 'good'`) o se agotan los intentos. Siempre devuelve
la MEJOR candidata por score (best-of-N): nunca devuelve basura por agotar
iteraciones.
Es la doctrina del issue 0087 (promover una secuencia repetida a un pipeline
one-shot) aplicada al bucle de mejora del grupo `comfyui-skill`: build -> submit
-> wait -> fetch -> judge -> (refine) -> repeat. Compone funciones del registry:
<builder>_py_ml (workflow de nodos en API format)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco)
comfyui_judge_image_py_ml (panel estetico + CLIP + critica LLM)
ask_llm_py_core (refine del prompt con el feedback)
Pipeline impuro: red (HTTP), GPU (generacion), disco (PNG), y API (juez critico
+ refine de prompt). Determinista en estructura: nunca lanza excepcion cruda,
siempre devuelve un dict de estado.
"""
from __future__ import annotations
import importlib
import inspect
import os
import sys
# Importa las funciones del registry (mismo arbol python/functions).
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _FUNCTIONS_ROOT not in sys.path:
sys.path.insert(0, _FUNCTIONS_ROOT)
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_judge_image import comfyui_judge_image
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
# Primo grande para derrochar el espacio de seeds entre rerolls de forma
# determinista (mismo subject + mismo base_seed -> misma traza de seeds).
_SEED_STRIDE = 101_117
def _resolve_builder(builder):
"""Devuelve el callable del builder.
Acepta un callable directo o el nombre de la funcion (string), que se
resuelve desde el paquete `ml` (convencion del registry: el modulo se llama
igual que la funcion, p.ej. `comfyui_build_ui_hud_workflow`).
"""
if callable(builder):
return builder
if isinstance(builder, str):
mod = importlib.import_module(f"ml.{builder}")
return getattr(mod, builder)
raise TypeError(
f"builder debe ser callable o str (nombre de funcion ml.*), no {type(builder)}"
)
def _extract_positive_prompt(workflow: dict) -> str:
"""Extrae el prompt positivo textual del workflow para pasarselo al juez.
Sigue el input `positive` del KSampler hasta su CLIPTextEncode. Fallback: el
CLIPTextEncode con el texto mas largo (heuristica: el positive suele serlo).
"""
if not isinstance(workflow, dict):
return ""
for node in workflow.values():
if not isinstance(node, dict):
continue
if node.get("class_type") in ("KSampler", "KSamplerAdvanced"):
pos = node.get("inputs", {}).get("positive")
if isinstance(pos, list) and pos:
tgt = workflow.get(str(pos[0]))
if isinstance(tgt, dict) and tgt.get("class_type") == "CLIPTextEncode":
txt = tgt.get("inputs", {}).get("text")
if isinstance(txt, str) and txt.strip():
return txt
texts = [
n["inputs"]["text"]
for n in workflow.values()
if isinstance(n, dict)
and n.get("class_type") == "CLIPTextEncode"
and isinstance(n.get("inputs", {}).get("text"), str)
]
return max(texts, key=len) if texts else ""
def _builder_default(sig: inspect.Signature, name: str, fallback):
"""Default declarado de un parametro del builder, o el fallback dado."""
p = sig.parameters.get(name)
if p is None or p.default is inspect.Parameter.empty:
return fallback
return p.default if isinstance(p.default, (int, float)) else fallback
def _refine_subject(subject: str, judge_prompt: str, reasons, model: str) -> str:
"""Reescribe el subject corrigiendo lo que el juez senalo, via ask_llm.
Devuelve el subject mejorado (string corto) o el original si el LLM falla.
"""
from core.ask_llm import ask_llm
complaints = "; ".join(str(r) for r in (reasons or []) if r) or "(sin razones)"
system = (
"Eres un prompt-engineer de generacion de imagenes. Recibes el SUBJECT de "
"una imagen rechazada por un juez de calidad y la lista de quejas del juez. "
"Devuelve un SUBJECT mejorado y conciso (una frase, en ingles) que conserve la "
"intencion original pero corrija las quejas anadiendo descriptores visuales "
"concretos (p.ej. 'clean vector UI, sharp edges, high contrast, crisp lines' "
"si era borroso). NO escribas explicaciones, NO uses comillas: responde SOLO "
"con el subject mejorado."
)
user = (
f"SUBJECT original: {subject}\n"
f"Prompt completo generado: {judge_prompt}\n"
f"Quejas del juez: {complaints}\n"
"SUBJECT mejorado:"
)
try:
out = ask_llm(user, model=model, system=system, echo=False)
out = (out or "").strip().strip('"').strip()
return out or subject
except Exception: # noqa: BLE001 — refine es best-effort; nunca rompe el loop.
return subject
def comfyui_generate_until_quality(
builder,
subject: str,
*,
threshold: float = 6.0,
clip_threshold: float = 0.24,
max_iters: int = 4,
strategy: str = "reroll+escalate+refine_prompt",
server: str = "127.0.0.1:8188",
dest_dir: str = "~/ComfyUI/output",
judge_prompt: str | None = None,
seed: int = 0,
refine_model: str = "claude-haiku-4-5-20251001",
judge_model: str = "claude-opus-4-8",
wait_timeout: float = 300.0,
**builder_kwargs,
) -> dict:
"""Genera y refina hasta alcanzar la calidad pedida (o agotar intentos).
Args:
builder: callable o nombre (str) de un builder `comfyui_build_*_workflow`
del registry. El `subject` se pasa como PRIMER positional del builder
(caso de los builders de asset: ui_hud, item_icon, enemy_creature...,
cuyo primer arg es el elemento/sujeto).
subject: descripcion del elemento a generar (p.ej. "RPG health and mana
bars" para `comfyui_build_ui_hud_workflow`). Se inyecta en el builder
y, si se refina, se reescribe con el feedback del juez.
threshold: umbral estetico (0-10) que el juez usa para votar good/bad.
keyword-only.
clip_threshold: umbral de fidelidad CLIP (0-1) del juez. keyword-only.
max_iters: numero maximo de iteraciones de generacion. keyword-only.
strategy: combinacion de tacticas de mejora separadas por '+':
'reroll' (seed nueva cada iter), 'escalate' (mas steps/cfg en iters
tardias) y 'refine_prompt' (reescribe el subject con ask_llm usando
las razones del juez). keyword-only.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio local donde guardar los PNG. keyword-only.
judge_prompt: texto que se pasa al juez para medir fidelidad. Si None,
se extrae el prompt positivo del workflow construido. keyword-only.
seed: semilla base; los rerolls derivan de ella de forma determinista.
keyword-only.
refine_model: modelo de ask_llm para el refine del prompt (barato).
judge_model: modelo del juez critico LLM-vision. keyword-only.
wait_timeout: segundos maximos esperando cada generacion. keyword-only.
**builder_kwargs: parametros extra del builder (ui_style, checkpoint,
size, transparent...). Solo se pasan los que el builder acepta.
Returns:
dict {ok, converged, best_image_path, best_score, best_verdict,
iterations, error}. `iterations` es una lista de
{iter, seed, params, score, verdict, reasons, image, error}. `converged`
True si alguna iteracion logro verdict 'good'. `best_*` apuntan a la
candidata de mayor score (aunque ninguna convergiera). Si nada se pudo
generar, ok=False y error explica.
"""
parts = {p.strip() for p in str(strategy).split("+") if p.strip()}
do_reroll = "reroll" in parts
do_escalate = "escalate" in parts
do_refine = "refine_prompt" in parts
try:
builder_fn = _resolve_builder(builder)
except (ImportError, AttributeError, TypeError) as exc:
return {
"ok": False, "converged": False, "best_image_path": "",
"best_score": None, "best_verdict": "", "iterations": [],
"error": f"no se pudo resolver el builder: {exc}",
}
sig = inspect.signature(builder_fn)
accepts = set(sig.parameters)
base_steps = builder_kwargs.get("steps", _builder_default(sig, "steps", 28))
base_cfg = builder_kwargs.get("cfg", _builder_default(sig, "cfg", 7.0))
prefix = builder_kwargs.get("filename_prefix", "until_quality")
dest = os.path.expanduser(dest_dir)
subject_cur = subject
iterations: list[dict] = []
best: dict | None = None
converged = False
for i in range(max(1, int(max_iters))):
# --- parametros de esta iteracion segun la estrategia ---
cur_seed = (seed + i * _SEED_STRIDE) if do_reroll else seed
kw = dict(builder_kwargs)
if "seed" in accepts:
kw["seed"] = cur_seed
if do_escalate and i > 0:
if "steps" in accepts:
kw["steps"] = int(base_steps) + i * 8 # mas pasos = mas nitidez
if "cfg" in accepts:
kw["cfg"] = round(min(float(base_cfg) + i * 0.5, 12.0), 2)
if "filename_prefix" in accepts:
kw["filename_prefix"] = f"{prefix}_i{i}"
# Solo pasamos kwargs que el builder acepta (evita TypeError entre builders).
kw = {k: v for k, v in kw.items() if k in accepts}
params = {
"seed": cur_seed,
"steps": kw.get("steps", base_steps),
"cfg": kw.get("cfg", base_cfg),
"subject": subject_cur,
}
rec = {"iter": i, "seed": cur_seed, "params": params, "score": None,
"verdict": "", "reasons": [], "image": "", "error": ""}
# --- build ---
try:
workflow = builder_fn(subject_cur, **kw)
except Exception as exc: # noqa: BLE001 — registra y reintenta siguiente iter.
rec["error"] = f"build fallo: {exc}"
iterations.append(rec)
continue
jp = judge_prompt if judge_prompt else _extract_positive_prompt(workflow)
# --- submit ---
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
except (RuntimeError, KeyError) as exc:
rec["error"] = f"submit fallo: {exc}"
iterations.append(rec)
continue
# --- wait ---
try:
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError) as exc:
rec["error"] = f"wait fallo: {exc}"
iterations.append(rec)
continue
# --- localizar el PNG ---
img = None
for node_out in outputs.values():
images = node_out.get("images") if isinstance(node_out, dict) else None
if images:
img = images[0]
break
if img is None:
rec["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
iterations.append(rec)
continue
# --- fetch ---
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=dest,
)
if not fetched.get("ok"):
rec["error"] = f"fetch fallo: {fetched.get('error')}"
iterations.append(rec)
continue
rec["image"] = fetched["path"]
# --- judge (degrada con gracia si un juez cae) ---
try:
verdict = comfyui_judge_image(
fetched["path"], jp, threshold=threshold,
clip_threshold=clip_threshold, server=server, model=judge_model,
)
except Exception as exc: # noqa: BLE001 — un juez caido no debe tumbar el loop.
verdict = {"ok": False, "verdict": "unknown", "score": 0.0,
"reasons": [f"juez no disponible: {exc}"]}
rec["score"] = float(verdict.get("score") or 0.0)
rec["verdict"] = verdict.get("verdict", "unknown")
rec["reasons"] = list(verdict.get("reasons") or [])
iterations.append(rec)
# --- best-of-N: guarda siempre la mejor por score ---
if best is None or rec["score"] > best["score"]:
best = rec
# --- convergencia ---
if rec["verdict"] == "good":
converged = True
break
# --- refine para la siguiente iteracion ---
if do_refine and i < max_iters - 1:
subject_cur = _refine_subject(subject_cur, jp, rec["reasons"], refine_model)
if best is None:
last_err = iterations[-1]["error"] if iterations else "sin iteraciones"
return {
"ok": False, "converged": False, "best_image_path": "",
"best_score": None, "best_verdict": "", "iterations": iterations,
"error": f"ninguna iteracion produjo imagen ({last_err})",
}
return {
"ok": True,
"converged": converged,
"best_image_path": best["image"],
"best_score": best["score"],
"best_verdict": best["verdict"],
"iterations": iterations,
"error": "",
}
if __name__ == "__main__":
import json
res = comfyui_generate_until_quality(
"comfyui_build_ui_hud_workflow",
"RPG health and mana bars, clean game UI",
ui_style="fantasy game UI, clean vector, high contrast",
threshold=6.5,
max_iters=3,
dest_dir="/tmp/comfy_until_quality",
transparent=False,
)
print(json.dumps(res, indent=2))