Compare commits

..

24 Commits

Author SHA1 Message Date
egutierrez ccdd529bdc feat(comfyui): pipeline comfyui_pixelart_real_oneshot — pixelart REAL (PixelOE + cuantizacion dura)
Materializa el metodo ganador del report 0215: generar a alta-res con SDXL +
LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe para
sprites/personajes) o nearest (tiles), y cuantizacion dura con
comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy).

- pixeloe_downscale_py_ml: downscale contrast-aware via lib pixeloe con bridge
  de interprete (la lib vive en el venv de ComfyUI, no en el del registry).
  No-throw, fallback limpio si pixeloe no disponible.
- comfyui_pixelart_real_oneshot_py_pipelines: one-shot que compone build_pixelart
  + submit + wait + fetch + pixeloe_downscale + pixelize_image. Fallback
  automatico pixeloe->nearest. Sweet-spot 64px personajes, 32px iconos.

Verificado por PIL: personaje 64x64=16 colores, icono 32x32=16 colores (vs ~33k
de la imagen de difusion cruda). 100% grid duro + outline nitido.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:15 +02:00
egutierrez 741724f633 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 15:03:24 +02:00
egutierrez 2be62f6ef6 merge(comfyui): comfyui_generate_until_quality — loop generar/juzgar/refinar (best-of-N + escalate + refine_prompt) 2026-06-28 15:02:45 +02:00
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
egutierrez f8793f96ac fix(comfyui): firmas sin keyword-only para que fn run las despache
El generador de runner de fn run (cmd/fn/pyrunner.go::generatePyRunner)
parsea la signature de la funcion desde el frontmatter del .md y emite
`<param> = _args[i]` por cada parametro posicional. Cuando la firma es
keyword-only (`def f(*, ...)`), el `*` se trata como un nombre de parametro
y genera la linea invalida `* = _args[0]`, que rompe el runner con
`SyntaxError: invalid syntax` antes de ejecutar la funcion.

Se quita el separador keyword-only (`*,`) de la firma — tanto en la `def`
del .py como en el campo `signature:` del .md (la fuente que lee el
indexer y el runner) — convirtiendo los parametros keyword-only en
parametros normales con su mismo default. No cambia nombres, defaults ni
comportamiento: las llamadas con keyword siguen siendo validas.

Afecta a 5 funciones detectadas en el report 0208 §3.3, todas con
SyntaxError reproducido via `fn run <id>`:
- comfyui_fetch_civitai_image_meta
- comfyui_load_skill
- comfyui_save_skill
- comfyui_import_workflow_png
- comfyui_list_skills

Se completa ademas el fix de comfyui_interrupt_queue: el commit 643ebfb8
quito el `*,` del .py pero dejo el `*,` en el campo `signature:` del .md,
que es justo lo que lee el runner — por eso `fn run comfyui_interrupt_queue`
seguia fallando. Aqui se corrige el .md.

Verificado: tras el cambio las 6 despachan sin SyntaxError (las 4 con
primer arg requerido devuelven el `missing required arg` esperado del
runner; list_skills e interrupt_queue ejecutan `ok:true`). Tests
existentes verdes (comfyui_fetch_civitai_image_meta_test.py +
tests/test_comfyui_interrupt_queue.py: 8 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:23:59 +02:00
egutierrez 643ebfb849 fix(comfyui): comfyui_interrupt_queue firma sin keyword-only para que fn run la despache 2026-06-28 04:55:39 +02:00
egutierrez 537516e32e merge(comfyui): comfyui_interrupt_queue — control de cola (interrupt + clear_pending) 2026-06-28 04:54:46 +02:00
egutierrez ca07b25297 feat(comfyui): comfyui_interrupt_queue v1.1.0 — clear_pending + cleared/queue_remaining + tests
Alinea la funcion al contrato de control de cola (punto 3 del roadmap ComfyUI):
- firma keyword-only: clear_pending (vacia pendientes con POST /queue {clear:true}) + timeout
- output {ok, interrupted, cleared, queue_remaining, error}; GET /queue al final
- no lanza en fallo de red: degrada a {ok:False, error}
- test con mock HTTP local (golden + clear + cola vacia + error path), 4/4 verde
- .md autosuficiente con gotchas + capability growth log

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:54:14 +02:00
egutierrez fbbff7d5e7 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 04:48:32 +02:00
egutierrez bdd841d9af merge(comfyui): higiene — 5 funciones de la sesión en capability page + tests list_templates/extract_template 2026-06-28 04:47:48 +02:00
egutierrez 7d33b39859 docs(comfyui): consolidar las 5 funciones nuevas del grupo (tests + capability page)
Higiene del grupo comfyui sobre las 5 funciones de la sesión:
comfyui_build_audio_workflow, comfyui_fetch_output_audio,
comfyui_build_flux_workflow, comfyui_list_templates, comfyui_extract_template.

- Tests nuevos para list_templates y extract_template (lógica pura: localización
  del intérprete, error-path sin el paquete instalado, contrato del dict; golden
  condicional con skip si no hay ComfyUI con comfyui-workflow-templates). 10 tests,
  todos verdes.
- comfyui_list_templates.md / comfyui_extract_template.md: tested true + tests +
  test_file_path.
- Fix drift de test_file_path en comfyui_fetch_output_audio.md (apuntaba a un
  *_test.py inexistente; corregido a tests/test_*.py). Elimina el WARN de fn index.
- docs/capabilities/comfyui.md: subsecciones Audio (ACE-Step) y Templates oficiales.
- docs/capabilities/comfyui-overview.md: sección 05b audio, fetch_output_audio en
  Outputs, Templates oficiales en Workflows I/O. (flux ya estaba documentada.)

fn index limpio (las 5 sin WARN); sin drift nuevo en fn doctor uses-functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:46:47 +02:00
egutierrez a1074d32e7 fix(test): corregir sys.path del test de comfyui_fetch_output_audio 2026-06-27 20:51:09 +02:00
egutierrez fd16453691 feat(ml): generación de audio en ComfyUI (ACE-Step) — comfyui_build_audio_workflow + comfyui_fetch_output_audio 2026-06-27 20:50:34 +02:00
egutierrez 5494507c39 chore: auto-commit (2 archivos)
- .mcp.json
- logs/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-27 20:43:03 +02:00
egutierrez dfb3eda087 merge(ml): comfyui_build_flux_workflow — builder Flux schnell+dev (custom-advanced) 2026-06-27 20:39:04 +02:00
egutierrez 83738d4035 merge(ml): comfyui_list_templates + comfyui_extract_template (extraer grafos de templates oficiales) 2026-06-27 20:37:18 +02:00
Egutierrez b77d223f01 feat(ml): comfyui_build_flux_workflow — builder Flux schnell+dev (camino custom-advanced)
Builder puro que arma el workflow ComfyUI de Flux en API format con el camino
canonico custom-advanced (UNETLoader + DualCLIPLoader[flux] + VAELoader ->
RandomNoise + KSamplerSelect + BasicScheduler -> BasicGuider ->
SamplerCustomAdvanced -> VAEDecode -> SaveImage).

- variant 'schnell' (~4 pasos, sin FluxGuidance) o 'dev' (~20 pasos, con
  FluxGuidance), con unet y steps por defecto por variante.
- Parametro 'available' opcional valida los modelos contra /object_info y lanza
  FileNotFoundError claro (que falta + carpeta) sin romper la pureza.
- width/height/seed/guidance/prefijo parametrizables.
- 11 tests unitarios (class_types schnell vs dev, defaults por variante, error
  path, determinismo). Verificado con generaciones reales (schnell 1024 y 768,
  dev 768x1024) que producen PNG en disco.
2026-06-27 20:36:55 +02:00
72 changed files with 4412 additions and 275 deletions
+4
View File
@@ -15,6 +15,10 @@
"godot": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp"
},
"ardour": {
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
"args": []
}
}
}
+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
+20 -1
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,11 +71,25 @@ 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).
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
### 05b · audio
- `comfyui_build_audio_workflow_py_ml` (pura) — txt2audio ACE-Step: TextEncodeAceStepAudio (tags + lyrics) → EmptyAceStepLatentAudio → KSampler → VAEDecodeAudio → SaveAudio(.flac).
### 06 · upscale / detail
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
@@ -102,9 +120,10 @@ sus IDs reales cuando se ejecute `fn index`.
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`.
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`, `comfyui_fetch_output_audio_py_ml`.
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
- Templates oficiales (paquete `comfyui-workflow-templates`): `comfyui_list_templates_py_ml`, `comfyui_extract_template_py_ml`.
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
## Librería de grafos en disco
+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.
+56 -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. |
@@ -142,6 +153,19 @@ canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. |
### Audio (txt2audio, ACE-Step) — dominio `ml` (tag `audio-generation`)
ComfyUI ≥ 0.26.0 trae nodos de **audio nativos**. `build_audio_workflow` cubre **ACE-Step v1**
(`AUDIO_ace_step_v1_3.5b.safetensors`, Apache 2.0): música y SFX por texto, con `lyrics` opcional
para voz cantada. El resultado es un `.flac` vía `VAEDecodeAudio → SaveAudio`, que `fetch_output_audio`
localiza y baja a disco (los nodos de audio exponen su salida bajo la clave `"audio"` de `/history`,
no `"images"`).
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_build_audio_workflow_py_ml](../../python/functions/ml/comfyui_build_audio_workflow.md) | `build_audio_workflow(ckpt_name, prompt, *, lyrics='', seconds=10.0, seed=0, steps=50, cfg=5.0, sampler_name='euler', scheduler='simple', shift=5.0, lyrics_strength=1.0, filename_prefix='audio/comfy_audio') -> dict` | Builder **txt2audio (ACE-Step)** en API format: CheckpointLoaderSimple → TextEncodeAceStepAudio (tags=prompt + lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) → ModelSamplingSD3(shift) → KSampler → VAEDecodeAudio → SaveAudio(.flac). La guía va por `cfg`; `lyrics` opcional para voz cantada. **Pura**. |
| [comfyui_fetch_output_audio_py_ml](../../python/functions/ml/comfyui_fetch_output_audio.md) | `fetch_output_audio(prompt_id, *, server='127.0.0.1:8188', dest=None, outputs=None, timeout=120.0) -> dict` | Localiza y descarga el output de **audio** (`.flac`/`.wav`/`.mp3`/`.opus`/`.ogg`/`.m4a`) de `/history` vía GET `/view`. Cubre SaveAudio/SaveAudioMP3/Opus/Advanced (bajo la clave `"audio"`). Hermana de `fetch_output_image`/`video`/`mesh`. Acepta `outputs=` de `wait_result` para no re-consultar `/history`. Impura. |
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
@@ -179,6 +203,37 @@ report `0079`).
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
### Templates oficiales — dominio `ml` (tag `templates`)
Los workflows del menú **"Browse Templates"** del frontend se distribuyen en el paquete pip
`comfyui-workflow-templates` (desde la 0.10.x un meta-paquete multi-bundle con API en
`comfyui_workflow_templates_core`). Estas dos funciones leen ese catálogo localizando el intérprete
de ComfyUI y usando su API oficial vía subprocess (el paquete vive en el venv de ComfyUI, no en el
del registry). Sirven para descubrir grafos oficiales y arrancar un workflow desde una plantilla
probada en vez de construirlo a mano. Si no hay un ComfyUI con el paquete, devuelven `ok=False` con
un error accionable, sin lanzar.
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
| ID | Firma corta | Qué hace |
|---|---|---|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
+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
---
@@ -0,0 +1,99 @@
---
name: comfyui_build_audio_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_audio_workflow(ckpt_name: str, prompt: str, *, lyrics: str = \"\", seconds: float = 10.0, seed: int = 0, steps: int = 50, cfg: float = 5.0, sampler_name: str = \"euler\", scheduler: str = \"simple\", shift: float = 5.0, lyrics_strength: float = 1.0, filename_prefix: str = \"audio/comfy_audio\") -> dict"
description: "Construye el dict de un workflow ComfyUI texto->audio (ACE-Step) en API format. Cadena con nodos de audio NATIVOS de ComfyUI 0.26.0: CheckpointLoaderSimple(AUDIO_ace_step_v1_3.5b.safetensors -> MODEL, CLIP, VAE) -> TextEncodeAceStepAudio(tags=prompt, lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) -> ModelSamplingSD3(shift) -> KSampler -> VAEDecodeAudio -> SaveAudio(.flac). ACE-Step es abierto (Apache 2.0). Genera musica y SFX por texto; lyrics opcional para voz cantada. Pura, sin red ni I/O. Hermana de audio de comfyui_build_txt2img_workflow."
tags: [comfyui, audio, ace-step, sfx, music, ml, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ckpt_name
desc: "Nombre del checkpoint ACE-Step tal como lo ve el servidor ComfyUI (ej. 'AUDIO_ace_step_v1_3.5b.safetensors', todo-en-uno: DiT + text encoder + VAE de audio). Debe estar entre los que devuelve comfyui_object_info en CheckpointLoaderSimple."
- name: prompt
desc: "Descripcion del sonido o estilo musical. Va al campo 'tags' de TextEncodeAceStepAudio. Ej. '8-bit coin pickup sound, retro game' o 'lofi hip hop, mellow piano, 90 bpm'."
- name: lyrics
desc: "Letra cantada para musica con voz. Vacio '' para SFX o musica instrumental. keyword-only."
- name: seconds
desc: "Duracion del audio en segundos (min 1.0). Controla el tamano del latente via EmptyAceStepLatentAudio. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar el resultado. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. 50 recomendado para ACE-Step. keyword-only."
- name: cfg
desc: "Classifier-free guidance scale. 5.0 recomendado para ACE-Step. keyword-only."
- name: sampler_name
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
- name: scheduler
desc: "Scheduler del KSampler. Por defecto 'simple'. keyword-only."
- name: shift
desc: "Shift del ModelSamplingSD3 aplicado al MODEL antes del sampling. 5.0 recomendado para ACE-Step; mejora la coherencia temporal. keyword-only."
- name: lyrics_strength
desc: "Fuerza del condicionamiento de la letra (1.0 por defecto; sin efecto practico cuando lyrics esta vacio). keyword-only."
- name: filename_prefix
desc: "Prefijo del .flac generado por SaveAudio en output/ del servidor. keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 8 nodos: CheckpointLoaderSimple, TextEncodeAceStepAudio, ConditioningZeroOut, EmptyAceStepLatentAudio, ModelSamplingSD3, KSampler, VAEDecodeAudio y SaveAudio. El denoise del KSampler se fija a 1.0 (genera desde el latente vacio, no es audio2audio)."
tested: true
tests: ["estructura: 8 nodos ACE-Step presentes + ckpt en CheckpointLoaderSimple + prompt en TextEncodeAceStepAudio.tags", "cableado: clip [4,1], positive [6,0], negative via ConditioningZeroOut [10,0], model post ModelSamplingSD3 [11,0], vae [4,2], denoise 1.0", "params reflejados (lyrics/seconds/seed/steps/cfg/sampler_name/scheduler/shift/lyrics_strength/filename_prefix)", "edge: seconds y seed variables se reflejan en EmptyAceStepLatentAudio y KSampler", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_audio_workflow.py"
file_path: "python/functions/ml/comfyui_build_audio_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_audio_workflow import comfyui_build_audio_workflow
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0, seed=42,
)
# wf["6"]["class_type"] == "TextEncodeAceStepAudio"
# wf["9"]["class_type"] == "SaveAudio"
# -> comfyui_submit_workflow(wf, server="127.0.0.1:8188") para encolar (necesita GPU)
# -> comfyui_wait_result(prompt_id) -> comfyui_fetch_output_audio(prompt_id, dest=...)
```
O lanzable directo con: `./fn run comfyui_build_audio_workflow` (imprime el JSON del workflow ACE-Step de ejemplo).
## Cuando usarla
Antes de enviar una generacion de audio (musica o SFX por texto) a ComfyUI:
construye aqui el dict del workflow ACE-Step y pasalo a `comfyui_submit_workflow`.
Usala cuando quieres un sonido o pieza musical descrita en lenguaje natural
(`prompt`), opcionalmente con letra cantada (`lyrics`). Baja el resultado con
`comfyui_fetch_output_audio`. Verifica el workflow contra el servidor con
`comfyui_validate_workflow` antes de encolar.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
acepta POST /prompt.
- El checkpoint ACE-Step debe existir y ser visible para el servidor (carpeta de
checkpoints o extra_model_paths) o ComfyUI rechaza el workflow con HTTP 400 al
enviarlo. Esta funcion es pura y no valida contra el servidor.
- Stable Audio Open 1.0 (la otra via nativa, mas ligera) esta GATED en HuggingFace
(resolve da HTTP 403 sin aceptar la licencia): por eso el modelo por defecto es
ACE-Step, que es abierto (Apache 2.0) y no gated.
- VRAM 8GB: `ace_step_v1_3.5b.safetensors` pesa ~7.7GB. Arrancar ComfyUI con
`--lowvram` para que streamee bloques a CPU; aun asi va justo. Antes de generar
audio, liberar VRAM de SD/Flux con POST /free {"unload_models":true,
"free_memory":true}. Si da OOM, bajar `seconds`. El builder es puro: no toca la
GPU, solo arma el dict (un OOM ocurre en el submit posterior, no aqui).
- ACE-Step es modelo de MUSICA: para SFX cortos funciona pero el resultado tiende
a sonar "musical". `seconds` minimo 1.0. Para SFX muy cortos usar 2-4 s.
- SaveAudio guarda `.flac` por defecto (clave "audio" en outputs[node]). Para bajar
el archivo usa `comfyui_fetch_output_audio` (no `comfyui_fetch_output_video`, que
solo busca extensiones de video).
- `lyrics` vacio = instrumental/SFX. Con letra, ACE-Step canta; `lyrics_strength`
ajusta cuanto se ciñe a ella.
@@ -0,0 +1,126 @@
"""Construye un workflow ComfyUI de texto->audio (ACE-Step) en "API format".
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
El grafo usa los nodos de audio NATIVOS de ComfyUI 0.26.0 para el modelo
ACE-Step (abierto, Apache 2.0): CheckpointLoaderSimple ->
TextEncodeAceStepAudio (tags + lyrics) -> EmptyAceStepLatentAudio ->
ModelSamplingSD3 -> KSampler -> VAEDecodeAudio -> SaveAudio. El negative se
construye con ConditioningZeroOut sobre el positive (patron oficial de ACE-Step).
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
"""
def comfyui_build_audio_workflow(
ckpt_name: str,
prompt: str,
*,
lyrics: str = "",
seconds: float = 10.0,
seed: int = 0,
steps: int = 50,
cfg: float = 5.0,
sampler_name: str = "euler",
scheduler: str = "simple",
shift: float = 5.0,
lyrics_strength: float = 1.0,
filename_prefix: str = "audio/comfy_audio",
) -> dict:
"""Construye el dict del workflow texto->audio para ACE-Step.
Cadena de nodos: CheckpointLoaderSimple -> TextEncodeAceStepAudio (positivo)
+ ConditioningZeroOut (negativo) + EmptyAceStepLatentAudio -> ModelSamplingSD3
-> KSampler -> VAEDecodeAudio -> SaveAudio. SaveAudio escribe un .flac en la
carpeta output/<filename_prefix> del servidor ComfyUI.
Args:
ckpt_name: nombre del checkpoint ACE-Step tal como lo ve el servidor
(ej. "AUDIO_ace_step_v1_3.5b.safetensors"). Debe estar entre los que
devuelve comfyui_object_info en CheckpointLoaderSimple.
prompt: descripcion del sonido o estilo musical (va al campo "tags" de
TextEncodeAceStepAudio). Ej. "8-bit coin pickup sound, retro game".
lyrics: letra cantada para musica con voz. Vacio "" para SFX o musica
instrumental.
seconds: duracion del audio en segundos (min 1.0). Controla el tamano
del latente via EmptyAceStepLatentAudio.
seed: semilla del KSampler (cambia para variar el resultado).
steps: pasos de sampling del KSampler (50 recomendado para ACE-Step).
cfg: classifier-free guidance scale (5.0 recomendado para ACE-Step).
sampler_name: nombre del sampler (ej. "euler").
scheduler: scheduler del sampler (ej. "simple").
shift: shift del ModelSamplingSD3 aplicado al MODEL antes del sampling
(5.0 recomendado para ACE-Step). Mejora la coherencia temporal.
lyrics_strength: fuerza del condicionamiento de la letra (1.0 por
defecto; sin efecto practico cuando lyrics esta vacio).
filename_prefix: prefijo del .flac generado por SaveAudio en output/.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids ("3".."11") y cada valor tiene class_type + inputs.
"""
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
},
"6": {
"class_type": "TextEncodeAceStepAudio",
"inputs": {
"clip": ["4", 1],
"tags": prompt,
"lyrics": lyrics,
"lyrics_strength": lyrics_strength,
},
},
"10": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"5": {
"class_type": "EmptyAceStepLatentAudio",
"inputs": {"seconds": seconds, "batch_size": 1},
},
"11": {
"class_type": "ModelSamplingSD3",
"inputs": {"model": ["4", 0], "shift": shift},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["11", 0],
"positive": ["6", 0],
"negative": ["10", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecodeAudio",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveAudio",
"inputs": {"filename_prefix": filename_prefix, "audio": ["8", 0]},
},
}
if __name__ == "__main__":
import json
wf = comfyui_build_audio_workflow(
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
prompt="8-bit coin pickup sound, retro game, short",
seconds=4.0,
seed=42,
)
print(json.dumps(wf, indent=2))
@@ -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,11 +3,11 @@ name: comfyui_build_flux_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
version: "1.1.0"
purity: pure
signature: "def comfyui_build_flux_workflow(prompt: str, *, unet: str = \"IMG_flux1-schnell-fp8-e4m3fn.safetensors\", clip_l: str = \"clip_l.safetensors\", t5xxl: str = \"t5xxl_fp8_e4m3fn_scaled.safetensors\", vae: str = \"ae.safetensors\", width: int = 1024, height: int = 1024, steps: int = 4, guidance: float = 3.5, seed: int = 0, weight_dtype: str = \"fp8_e4m3fn\", sampler_name: str = \"euler\", scheduler: str = \"simple\", filename_prefix: str = \"comfy_flux\") -> dict"
description: "Construye el dict de un workflow ComfyUI txt2img con Flux en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]). A diferencia de SD1.5/SDXL, Flux carga por separado UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader; la guia va por FluxGuidance (no por el cfg del KSampler, que se fija a 1.0). Cadena: UNETLoader+DualCLIPLoader+VAELoader -> CLIPTextEncode -> FluxGuidance + EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, flux, ml, txt2img, workflow]
signature: "def comfyui_build_flux_workflow(prompt: str, *, variant: str = \"schnell\", width: int = 1024, height: int = 1024, steps: int | None = None, guidance: float = 3.5, seed: int = 0, unet_name: str | None = None, clip_l_name: str = \"clip_l.safetensors\", t5xxl_name: str = \"t5xxl_fp8_e4m3fn_scaled.safetensors\", vae_name: str = \"ae.safetensors\", weight_dtype: str = \"default\", sampler_name: str = \"euler\", scheduler: str = \"simple\", filename_prefix: str = \"flux\", available: dict | None = None) -> dict"
description: "Construye el dict de un workflow ComfyUI para Flux (schnell o dev) en API format (nodos numerados con class_type + inputs, conexiones como [node_id, output_index]). A diferencia de SD1.5/SDXL, Flux carga por separado UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader y muestrea con el camino custom-advanced: RandomNoise + KSamplerSelect + BasicScheduler -> BasicGuider -> SamplerCustomAdvanced -> VAEDecode -> SaveImage. variant=schnell (~4 pasos, sin FluxGuidance) o dev (~20 pasos, con FluxGuidance). Validacion opcional de modelos via 'available'. Pura, sin red ni I/O. Hermana de comfyui_build_txt2img_workflow."
tags: [comfyui, flux, ml, txt2img, workflow, image-generation]
uses_functions: []
uses_types: []
returns: []
@@ -16,36 +16,40 @@ error_type: ""
imports: []
params:
- name: prompt
desc: "Prompt positivo: lo que se quiere ver en la imagen."
- name: unet
desc: "Nombre del modelo de difusion en models/diffusion_models/ tal como lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto el Flux schnell fp8. keyword-only."
- name: clip_l
desc: "Nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del DualCLIPLoader). Por defecto 'clip_l.safetensors'. keyword-only."
- name: t5xxl
desc: "Nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del DualCLIPLoader). Por defecto 't5xxl_fp8_e4m3fn_scaled.safetensors'. keyword-only."
- name: vae
desc: "Nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto 'ae.safetensors', el autoencoder de Flux. keyword-only."
desc: "Prompt positivo: lo que se quiere ver. Flux ignora el negativo, por eso no se codifica."
- name: variant
desc: "'schnell' (rapido, ~4 pasos, sin FluxGuidance) o 'dev' (~20 pasos, con FluxGuidance). Determina el unet y los steps por defecto. keyword-only."
- name: width
desc: "Ancho del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
desc: "Ancho del latente/imagen en px, multiplo de 8. keyword-only."
- name: height
desc: "Alto del latente/imagen en px, multiplo de 16 para SD3/Flux. keyword-only."
desc: "Alto del latente/imagen en px, multiplo de 8. keyword-only."
- name: steps
desc: "Pasos de sampling del KSampler. Flux schnell rinde con ~4; Flux dev necesita ~20. keyword-only."
desc: "Pasos de sampling (BasicScheduler). Si None, default por variante: schnell=4, dev=20. keyword-only."
- name: guidance
desc: "Valor del nodo FluxGuidance (no es el cfg clasico). Schnell es poco sensible; dev responde a 3.0-4.0. keyword-only."
desc: "Valor del nodo FluxGuidance. Solo se aplica en variant=dev; en schnell se ignora (la guia va fija dentro del modelo distilado). dev responde a 3.0-4.0. keyword-only."
- name: seed
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar la imagen. keyword-only."
desc: "Semilla de RandomNoise. 0 es determinista; cambiar para variar la imagen. keyword-only."
- name: unet_name
desc: "Nombre del modelo de difusion en UNETLoader (unet_name de /object_info). Si None, default por variante (IMG_flux1-schnell-fp8-e4m3fn.safetensors / IMG_flux1-dev-fp8-e4m3fn.safetensors). keyword-only."
- name: clip_l_name
desc: "Nombre del encoder CLIP-L en DualCLIPLoader (clip_name2). Por defecto 'clip_l.safetensors'. keyword-only."
- name: t5xxl_name
desc: "Nombre del encoder T5-XXL en DualCLIPLoader (clip_name1). Por defecto 't5xxl_fp8_e4m3fn_scaled.safetensors'. keyword-only."
- name: vae_name
desc: "Nombre del VAE en VAELoader (vae_name). Por defecto 'ae.safetensors', el autoencoder de Flux. keyword-only."
- name: weight_dtype
desc: "dtype de carga del UNET (uno de 'default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). fp8 reduce VRAM, clave en GPU de 8GB. keyword-only."
desc: "dtype de carga del UNET (uno de 'default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). Los modelos ya son fp8; 'default' los carga tal cual. keyword-only."
- name: sampler_name
desc: "Nombre del sampler (Flux usa 'euler'). keyword-only."
desc: "Nombre del sampler para KSamplerSelect (Flux usa 'euler'). keyword-only."
- name: scheduler
desc: "Scheduler del sampler (Flux usa 'simple'). keyword-only."
desc: "Scheduler para BasicScheduler (Flux usa 'simple'). keyword-only."
- name: filename_prefix
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
output: "dict en API format con node_ids como claves (UNETLoader '10', DualCLIPLoader '11', VAELoader '12', CLIPTextEncode positivo '6', FluxGuidance '13', CLIPTextEncode negativo vacio '7', EmptySD3LatentImage '5', KSampler '3', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
- name: available
desc: "Mapa opcional para validar que los modelos existen en el servidor, con claves opcionales 'unet', 'clip', 'vae' (cada una lista de nombres de /object_info). Si se pasa y un modelo elegido falta, lanza FileNotFoundError indicando que falta y donde colocarlo. None = sin validacion. keyword-only."
output: "dict en API format con node_ids string como claves (UNETLoader '12', DualCLIPLoader '11', VAELoader '10', EmptyLatentImage '5', CLIPTextEncode '6', FluxGuidance '21' solo en dev, RandomNoise '25', KSamplerSelect '16', BasicScheduler '17', BasicGuider '22', SamplerCustomAdvanced '13', VAEDecode '8', SaveImage '9'). Listo para comfyui_submit_workflow."
tested: true
tests: ["class_types esperados (9 nodos de Flux)", "loaders separados UNET+DualCLIP(flux)+VAE", "guidance via FluxGuidance y cfg del KSampler fijado a 1.0", "params width/height/steps/seed reflejados", "filename_prefix en SaveImage", "determinismo: misma entrada -> mismo dict (builder puro)"]
tests: ["class_types esperados del camino custom-advanced", "schnell: sin nodo FluxGuidance, BasicGuider consume CLIPTextEncode directo", "dev: nodo FluxGuidance presente con guidance, BasicGuider lo consume", "steps default por variante (schnell=4, dev=20)", "width/height/seed reflejados en sus nodos", "available: FileNotFoundError si falta un modelo", "variant invalido -> ValueError", "determinismo: misma entrada -> mismo dict (builder puro)"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_flux_workflow.py"
file_path: "python/functions/ml/comfyui_build_flux_workflow.py"
---
@@ -56,22 +60,38 @@ file_path: "python/functions/ml/comfyui_build_flux_workflow.py"
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_flux_workflow import comfyui_build_flux_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
# Flux schnell: rapido, ~4 pasos, sin FluxGuidance.
wf = comfyui_build_flux_workflow(
prompt="a red apple on a wooden table, sharp focus, studio lighting",
"a red apple on a wooden table, sharp focus, studio light",
variant="schnell",
width=1024,
height=1024,
steps=4, # Flux schnell: ~4 pasos basta
seed=42,
)
# wf["10"]["class_type"] == "UNETLoader" # modelo de difusion suelto
# wf["11"]["inputs"]["type"] == "flux" # DualCLIPLoader en modo flux
# wf["3"]["inputs"]["positive"] == ["13", 0] # KSampler consume FluxGuidance
# wf["3"]["inputs"]["cfg"] == 1.0 # la guia va por FluxGuidance
# wf["9"]["class_type"] == "SaveImage"
# wf["12"]["class_type"] == "UNETLoader" # modelo de difusion suelto
# wf["11"]["inputs"]["type"] == "flux" # DualCLIPLoader en modo flux
# "21" not in wf # schnell no lleva FluxGuidance
# wf["22"]["inputs"]["conditioning"] == ["6", 0] # BasicGuider <- CLIPTextEncode
sub = comfyui_submit_workflow(wf, server="127.0.0.1:8188")
out = comfyui_wait_result(sub["prompt_id"], server="127.0.0.1:8188")
img = out["9"]["images"][0]
res = comfyui_fetch_output_image(img["filename"], subfolder=img["subfolder"],
server="127.0.0.1:8188", dest_dir="/tmp")
print(res["path"]) # PNG en disco
# Flux dev: ~20 pasos, con FluxGuidance.
wf_dev = comfyui_build_flux_workflow("a misty forest at dawn", variant="dev",
guidance=3.5, width=768, height=1024)
# wf_dev["21"]["class_type"] == "FluxGuidance"
# wf_dev["22"]["inputs"]["conditioning"] == ["21", 0]
```
O lanzable directo con: `./fn run comfyui_build_flux_workflow` (imprime el JSON del workflow de ejemplo).
O lanzable directo con: `./fn run comfyui_build_flux_workflow` (imprime el JSON del workflow schnell de ejemplo).
## Cuando usarla
@@ -79,26 +99,34 @@ Cuando vayas a generar txt2img con un modelo Flux (schnell o dev) y necesites el
dict del workflow para `comfyui_submit_workflow`. Usala en lugar de
`comfyui_build_txt2img_workflow` siempre que el modelo NO sea un checkpoint
todo-en-uno SD1.5/SDXL sino Flux con UNET + text encoders + VAE por separado.
Flux schnell es ideal en GPU de poca VRAM (8GB) por el fp8 y los ~4 pasos.
Flux schnell es ideal en GPU de poca VRAM (8GB) por el fp8 y los ~4 pasos; dev
da mejor calidad a cambio de mas tiempo.
## Gotchas
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI (graph con
links). No se puede pegar en la UI tal cual; es el formato que acepta POST
/prompt.
- Flux NO usa el cfg del KSampler para guiar: este builder lo fija a 1.0 y la
guia va por el nodo FluxGuidance. Subir el cfg del KSampler con Flux degrada o
rompe la imagen.
- El negativo es un CLIPTextEncode vacio cableado al KSampler (igual que el
template oficial de Flux). Flux schnell es destilado y practicamente ignora el
negativo; no esperes que un prompt negativo tenga el efecto de SD1.5/SDXL.
- `unet`, `clip_l`, `t5xxl` y `vae` deben existir en los directorios respectivos
visibles para el servidor (models/diffusion_models/, models/text_encoders/,
models/vae/). Si no, ComfyUI rechaza el workflow con HTTP 400 al enviarlo (no
aqui — esta funcion es pura y no valida contra el servidor). Valida antes con
`comfyui_validate_workflow`.
- `width`/`height` deben ser multiplos de 16 para EmptySD3LatentImage (Flux), no
de 8 como en SD1.5/SDXL.
- `weight_dtype` debe ser uno de los que admite UNETLoader ('default',
'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'). En 8GB usa fp8 o el modelo no
cabe en VRAM.
- Camino de muestreo custom-advanced (RandomNoise + KSamplerSelect +
BasicScheduler -> BasicGuider -> SamplerCustomAdvanced), el patron oficial de
Flux. NO usa KSampler ni cfg; la guia va por FluxGuidance (solo en dev).
- schnell es destilado: NO lleva FluxGuidance y practicamente ignora el prompt
negativo. dev SI lleva FluxGuidance (nodo '21'); subir `guidance` aumenta la
adherencia al prompt.
- Los modelos (unet/clip_l/t5xxl/vae) deben existir en el servidor. Esta funcion
es pura y no toca disco: por defecto NO valida. Pasa `available` (las listas de
/object_info) para que valide y lance FileNotFoundError con la carpeta destino
si falta alguno, ANTES de enviar nada a la GPU. Sin `available`, un modelo
ausente lo detecta `comfyui_submit_workflow` (HTTP 400 con detalle).
- `width`/`height` deben ser multiplos de 8 (EmptyLatentImage). Flux trabaja bien
a 1024x1024; tamanos grandes suben mucho la VRAM en 8GB.
- Los `clip_name1`/`clip_name2` del DualCLIPLoader van en orden t5xxl, clip_l
(igual que el template oficial). El modo flux carga ambos; el orden no afecta
al resultado.
## Capability growth log
- v1.1.0 (27/06/2026) — refactor al camino custom-advanced (SamplerCustomAdvanced
+ BasicGuider), nuevo parametro `variant` (schnell/dev con steps por defecto),
FluxGuidance solo en dev, y `available` para validar modelos faltantes con
error claro (FileNotFoundError) sin romper la pureza.
@@ -1,136 +1,241 @@
"""Construye un workflow ComfyUI txt2img con Flux en "API format" (dict de nodos numerados).
"""Construye un workflow ComfyUI para Flux (schnell o dev) en "API format".
API format: cada clave es un node_id (string); cada nodo tiene class_type +
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
links explicitos).
A diferencia del builder SD1.5/SDXL (comfyui_build_txt2img_workflow), Flux NO usa
un checkpoint todo-en-uno: carga por separado el modelo de difusion (UNETLoader),
los dos text encoders (DualCLIPLoader con clip_l + t5xxl, type="flux") y el VAE
(VAELoader). La guia no va por el cfg del KSampler (que se fija a 1.0) sino por el
nodo FluxGuidance aplicado al condicionamiento positivo. El negativo se deja como
un CLIPTextEncode vacio, igual que el template oficial de Flux en ComfyUI.
Flux NO se carga como un checkpoint clasico (no CheckpointLoaderSimple). El
modelo de difusion se carga con UNETLoader; los dos text encoders (clip_l + t5xxl)
con DualCLIPLoader (type="flux"); el VAE con VAELoader. El muestreo usa el camino
"custom advanced" (RandomNoise -> KSamplerSelect + BasicScheduler -> BasicGuider
-> SamplerCustomAdvanced), que es el patron canonico de los ejemplos oficiales de
Flux y el que produce resultados estables con los modelos fp8 distilados.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
Diferencias schnell vs dev:
- schnell: modelo distilado, ~4 pasos, sin FluxGuidance (la guia va fija dentro
del modelo). Rapido. El conditioning del prompt va directo a BasicGuider.
- dev: ~20 pasos, el conditioning pasa antes por FluxGuidance (guidance ~3.5),
que sube la adherencia al prompt a costa de tiempo. Mejor calidad.
Flux ignora el prompt negativo, por eso solo se codifica el positivo.
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos. La
validacion de existencia de modelos en disco se hace pasando `available` (mapa
de modelos que el servidor expone via /object_info); recibir ese mapa como
argumento no rompe la pureza (el caller hace la unica peticion de red).
"""
# Modelos por defecto para cada variante (nombres tal como los expone el
# servidor ComfyUI en /object_info; verificados contra UNETLoader.unet_name,
# DualCLIPLoader.clip_name1/2 y VAELoader.vae_name).
_DEFAULT_UNET = {
"schnell": "IMG_flux1-schnell-fp8-e4m3fn.safetensors",
"dev": "IMG_flux1-dev-fp8-e4m3fn.safetensors",
}
_DEFAULT_STEPS = {"schnell": 4, "dev": 20}
# Carpeta destino por rol de modelo, para mensajes de error utiles. ComfyUI
# acepta tanto la carpeta "diffusion_models" (moderna) como "unet" (legacy) para
# el UNET; los text encoders en "text_encoders" o "clip"; el VAE en "vae".
_MODEL_DIRS = {
"unet": "models/diffusion_models/ (o models/unet/)",
"clip": "models/text_encoders/ (o models/clip/)",
"vae": "models/vae/",
}
def comfyui_build_flux_workflow(
prompt: str,
*,
unet: str = "IMG_flux1-schnell-fp8-e4m3fn.safetensors",
clip_l: str = "clip_l.safetensors",
t5xxl: str = "t5xxl_fp8_e4m3fn_scaled.safetensors",
vae: str = "ae.safetensors",
variant: str = "schnell",
width: int = 1024,
height: int = 1024,
steps: int = 4,
steps: int | None = None,
guidance: float = 3.5,
seed: int = 0,
weight_dtype: str = "fp8_e4m3fn",
unet_name: str | None = None,
clip_l_name: str = "clip_l.safetensors",
t5xxl_name: str = "t5xxl_fp8_e4m3fn_scaled.safetensors",
vae_name: str = "ae.safetensors",
weight_dtype: str = "default",
sampler_name: str = "euler",
scheduler: str = "simple",
filename_prefix: str = "comfy_flux",
filename_prefix: str = "flux",
available: dict | None = None,
) -> dict:
"""Construye el dict del workflow txt2img de Flux (schnell/dev).
"""Construye el dict del workflow Flux (schnell o dev) en API format.
Cadena de nodos: UNETLoader + DualCLIPLoader + VAELoader -> CLIPTextEncode
(positivo) -> FluxGuidance, mas un CLIPTextEncode vacio para el negativo y
EmptySD3LatentImage -> KSampler -> VAEDecode -> SaveImage.
[-> FluxGuidance solo en dev] -> BasicGuider; RandomNoise + KSamplerSelect +
BasicScheduler + EmptyLatentImage -> SamplerCustomAdvanced -> VAEDecode ->
SaveImage.
Args:
prompt: prompt positivo (lo que se quiere ver en la imagen).
unet: nombre del modelo de difusion en models/diffusion_models/ tal como
lo lista comfyui_object_info para UNETLoader (unet_name). Por defecto
el Flux schnell fp8 ("IMG_flux1-schnell-fp8-e4m3fn.safetensors").
clip_l: nombre del encoder CLIP-L en models/text_encoders/ (clip_name2 del
DualCLIPLoader). Por defecto "clip_l.safetensors".
t5xxl: nombre del encoder T5-XXL en models/text_encoders/ (clip_name1 del
DualCLIPLoader). Por defecto "t5xxl_fp8_e4m3fn_scaled.safetensors".
vae: nombre del VAE en models/vae/ (vae_name del VAELoader). Por defecto
"ae.safetensors" (el autoencoder de Flux).
width: ancho del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
height: alto del latente/imagen en px (multiplo de 16 para SD3/Flux). keyword-only.
steps: pasos de sampling del KSampler. Flux schnell rinde bien con ~4;
Flux dev necesita ~20. keyword-only.
guidance: valor del nodo FluxGuidance (no es el cfg clasico). Schnell es
poco sensible a este valor; dev responde a 3.0-4.0. keyword-only.
seed: semilla del KSampler (0 = determinista; cambia para variar). keyword-only.
weight_dtype: dtype de carga del UNET (uno de "default", "fp8_e4m3fn",
"fp8_e4m3fn_fast", "fp8_e5m2"). fp8 reduce VRAM (clave en 8GB). keyword-only.
sampler_name: nombre del sampler (Flux usa "euler"). keyword-only.
scheduler: scheduler del sampler (Flux usa "simple"). keyword-only.
filename_prefix: prefijo del PNG que SaveImage escribe en output/. keyword-only.
prompt: prompt positivo (lo que se quiere ver). Flux ignora el negativo.
variant: "schnell" (rapido, ~4 pasos, sin FluxGuidance) o "dev"
(~20 pasos, con FluxGuidance). keyword-only.
width: ancho del latente/imagen en px (multiplo de 8). keyword-only.
height: alto del latente/imagen en px (multiplo de 8). keyword-only.
steps: pasos de sampling. Si None, default por variante (schnell=4,
dev=20). keyword-only.
guidance: valor de FluxGuidance. Solo se aplica en variant="dev"; en
schnell se ignora (el modelo distilado lleva la guia fija).
keyword-only.
seed: semilla de RandomNoise (cambia para variar la imagen). keyword-only.
unet_name: nombre del modelo de difusion en UNETLoader. Si None, default
por variante. keyword-only.
clip_l_name: nombre del encoder CLIP-L en DualCLIPLoader. keyword-only.
t5xxl_name: nombre del encoder T5-XXL en DualCLIPLoader. keyword-only.
vae_name: nombre del VAE en VAELoader. keyword-only.
weight_dtype: dtype de los pesos del UNET ("default", "fp8_e4m3fn",
"fp8_e4m3fn_fast", "fp8_e5m2"). keyword-only.
sampler_name: sampler para KSamplerSelect (ej. "euler"). keyword-only.
scheduler: scheduler para BasicScheduler (ej. "simple"). keyword-only.
filename_prefix: prefijo del PNG generado por SaveImage en output/.
keyword-only.
available: mapa opcional para validar que los modelos existen en el
servidor, con claves opcionales "unet", "clip", "vae", cada una una
lista de nombres disponibles (tal como /object_info los expone). Si
se pasa y algun modelo elegido no esta en su lista, se lanza
FileNotFoundError indicando que falta y en que carpeta colocarlo.
Si es None (default), no se valida disco. keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son
node_ids (string) y cada valor tiene class_type + inputs.
node_ids string y cada valor tiene class_type + inputs.
Raises:
ValueError: si variant no es "schnell" ni "dev".
FileNotFoundError: si `available` se pasa y algun modelo (unet/clip/vae)
no esta disponible en el servidor; el mensaje lista los que faltan y
la carpeta donde colocarlos. La funcion NO crashea de forma opaca:
falla con un error claro y accionable antes de enviar nada a la GPU.
"""
return {
"10": {
if variant not in ("schnell", "dev"):
raise ValueError(
f"comfyui_build_flux_workflow: variant '{variant}' invalido; "
f"usa 'schnell' o 'dev'"
)
unet = unet_name or _DEFAULT_UNET[variant]
n_steps = steps if steps is not None else _DEFAULT_STEPS[variant]
# Error path: validar contra los modelos que expone el servidor, si el caller
# nos pasa el mapa. Pura (no toca disco; recibe las listas ya obtenidas).
if available is not None:
missing = []
checks = (
("unet", unet, available.get("unet")),
("clip", clip_l_name, available.get("clip")),
("clip", t5xxl_name, available.get("clip")),
("vae", vae_name, available.get("vae")),
)
for role, name, names in checks:
if names is not None and name not in names:
missing.append(
f" - '{name}' (rol {role}) no esta en el servidor; "
f"colocalo en {_MODEL_DIRS[role]}"
)
if missing:
raise FileNotFoundError(
"comfyui_build_flux_workflow: faltan modelos Flux en el "
"servidor:\n" + "\n".join(missing)
)
# Loaders (Flux no usa CheckpointLoaderSimple).
workflow: dict = {
"12": {
"class_type": "UNETLoader",
"inputs": {"unet_name": unet, "weight_dtype": weight_dtype},
},
"11": {
"class_type": "DualCLIPLoader",
"inputs": {
"clip_name1": t5xxl,
"clip_name2": clip_l,
"clip_name1": t5xxl_name,
"clip_name2": clip_l_name,
"type": "flux",
},
},
"12": {
"10": {
"class_type": "VAELoader",
"inputs": {"vae_name": vae},
"inputs": {"vae_name": vae_name},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["11", 0]},
},
"13": {
}
# Conditioning hacia BasicGuider. En dev pasa por FluxGuidance; en schnell va
# directo (el modelo distilado no usa guidance externo).
if variant == "dev":
workflow["21"] = {
"class_type": "FluxGuidance",
"inputs": {"conditioning": ["6", 0], "guidance": guidance},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["11", 0]},
},
"5": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": 1.0,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": ["10", 0],
"positive": ["13", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
}
guider_cond = ["21", 0]
else:
guider_cond = ["6", 0]
workflow.update(
{
"25": {
"class_type": "RandomNoise",
"inputs": {"noise_seed": seed},
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["12", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
},
}
"16": {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": sampler_name},
},
"17": {
"class_type": "BasicScheduler",
"inputs": {
"model": ["12", 0],
"scheduler": scheduler,
"steps": n_steps,
"denoise": 1.0,
},
},
"22": {
"class_type": "BasicGuider",
"inputs": {"model": ["12", 0], "conditioning": guider_cond},
},
"13": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["25", 0],
"guider": ["22", 0],
"sampler": ["16", 0],
"sigmas": ["17", 0],
"latent_image": ["5", 0],
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["13", 0], "vae": ["10", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
},
}
)
return workflow
if __name__ == "__main__":
import json
wf = comfyui_build_flux_workflow(
prompt="a red apple on a wooden table, sharp focus, studio lighting",
"a red apple on a wooden table, sharp focus, studio light",
variant="schnell",
width=1024,
height=1024,
seed=42,
)
print(json.dumps(wf, indent=2))
+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"
---
@@ -24,9 +24,13 @@ params:
- name: server
desc: "host:port del servidor ComfyUI usado para la conversion to_api (default '127.0.0.1:8188')."
output: "dict {ok, name, format, class_types, has_subgraphs, n_nodes, graph, api_workflow, api_error, bundle, version, assets, error}. graph = dict del template (formato UI o API). class_types = lista ordenada de tipos de nodo reales. api_workflow = dict API si to_api tuvo exito, si no {}. Nunca lanza: nombre inexistente -> ok=False con error + sugerencias."
tested: false
tests: []
test_file_path: ""
tested: true
tests:
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
- "el nombre pedido se preserva y el dict trae todas sus claves aun en fallo"
- "golden (skip si no hay ComfyUI con el paquete): extrae un template real con graph + class_types no vacios"
- "golden (skip si no hay ComfyUI con el paquete): nombre inexistente -> ok=False con error legible"
test_file_path: "python/functions/ml/tests/test_comfyui_extract_template.py"
file_path: "python/functions/ml/comfyui_extract_template.py"
---
@@ -5,7 +5,7 @@ lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_civitai_image_meta(image_ref, *, token: str | None = None, timeout: float = 15.0) -> dict"
signature: "def comfyui_fetch_civitai_image_meta(image_ref, token: str | None = None, timeout: float = 15.0) -> dict"
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
uses_functions: []
@@ -128,15 +128,15 @@ def _extract_comfy_workflow(meta):
return {}
def comfyui_fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0):
def comfyui_fetch_civitai_image_meta(image_ref, token=None, timeout=15.0):
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
Args:
image_ref: id numérico de la imagen (int o str), o su URL
`https://civitai.com/images/<id>` (con o sin query string).
token: API token de Civitai (header Authorization Bearer). Si None se
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
timeout: timeout HTTP en segundos por petición. keyword-only.
resuelve de `pass civitai/api-token`. No hardcodear.
timeout: timeout HTTP en segundos por petición.
Returns:
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
@@ -0,0 +1,85 @@
---
name: comfyui_fetch_output_audio
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_fetch_output_audio(prompt_id: str, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, outputs: dict | None = None, timeout: float = 120.0) -> dict"
description: "Localiza y descarga el output de audio de un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_video / _image / _mesh pero para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced): esos exponen su salida en GET /history bajo la clave 'audio' con items {filename, subfolder, type}. Localiza el primer .flac/.wav/.mp3/.opus/.ogg/.m4a, lo baja via GET /view y opcionalmente lo escribe en dest. Acepta outputs= ya obtenido de comfyui_wait_result para evitar re-consultar /history. Impura: HTTP GET + escritura en disco, solo stdlib."
tags: [comfyui, audio, fetch, ace-step, ml, download, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
params:
- name: prompt_id
desc: "id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo de audio (SaveAudio/SaveAudioMP3/...) ya termino (usa comfyui_wait_result antes si dudas). Se ignora si se pasa outputs."
- name: server
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
- name: dest
desc: "Ruta destino. Si None, escribe el basename del audio en el cwd. Si es un directorio existente (o termina en separador), escribe el basename dentro. Si es una ruta de archivo, escribe ahi. keyword-only."
- name: outputs
desc: "dict de outputs ya obtenido (el que devuelve comfyui_wait_result). Si se pasa, se busca el audio ahi y NO se consulta /history (evita una peticion de red extra). keyword-only."
- name: timeout
desc: "Timeout de cada peticion HTTP en segundos. keyword-only."
output: "dict {ok, path, format, bytes, error}. path = ruta local del archivo de audio guardado, format = extension sin punto (ej. 'flac' o 'mp3'), bytes = bytes descargados. Si falla, ok=False y error explica (sin audio en los outputs, HTTP, conexion o escritura)."
tested: true
tests:
- "test_is_audio_item_por_extension"
- "test_find_saveaudio_flac_bajo_audio"
- "test_find_saveaudiomp3_bajo_audio"
- "test_find_prioriza_clave_audio"
- "test_find_sin_audio_devuelve_none"
test_file_path: "python/functions/ml/tests/test_comfyui_fetch_output_audio.py"
file_path: "python/functions/ml/comfyui_fetch_output_audio.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_fetch_output_audio import comfyui_fetch_output_audio
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow de audio
# (ACE-Step, Stable Audio), baja el .flac/.mp3 al disco.
res = comfyui_fetch_output_audio("8a278988-8a94-4225-add3-88a406f7101c", dest="/tmp/audios")
# res == {"ok": True, "path": "/tmp/audios/comfy_audio_00001_.flac",
# "format": "flac", "bytes": 882000, "error": ""}
# Si ya tienes los outputs de comfyui_wait_result, pasalos y evita re-consultar /history:
outputs = {"9": {"audio": [{"filename": "comfy_audio_00001_.flac", "subfolder": "audio", "type": "output"}]}}
res2 = comfyui_fetch_output_audio("ignored", dest="/tmp/audios", outputs=outputs)
```
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
## Cuando usarla
Después de generar audio con ComfyUI (música o SFX por texto con ACE-Step, o Stable
Audio), cuando necesites el archivo `.flac`/`.wav`/`.mp3`/`.opus` real en disco (no
solo su nombre): para reproducirlo, subirlo a un vault, o usarlo como asset de un
juego. Es la hermana de `comfyui_fetch_output_video` (vídeo/animación),
`comfyui_fetch_output_image` (imágenes) y `comfyui_fetch_output_mesh` (mallas 3D).
El builder hermano es `comfyui_build_audio_workflow`.
## Gotchas
- Impura: hace HTTP GET a /history y /view y escribe en disco. Requiere el server
vivo y que el prompt YA haya terminado (usa `comfyui_wait_result` antes, o pásale
`outputs=`).
- Los nodos SaveAudio* exponen el archivo bajo la clave `"audio"` de los outputs
(no `"images"` como los de imagen/vídeo). Por eso `comfyui_fetch_output_video` NO
sirve para audio: busca extensiones de vídeo y claves gifs/videos/images.
- SaveAudio guarda `.flac` por defecto; SaveAudioMP3 `.mp3`, SaveAudioOpus `.opus`.
La función cubre todas por extensión.
- Toma el PRIMER archivo de audio que encuentra. Si un workflow exporta varios,
baja solo uno; para los demás llama otra vez o usa GET /view con el filename concreto.
- El history se purga al reiniciar el server: si el prompt ya no está, devuelve
`ok=False`. Pasar `outputs=` evita esa consulta y el problema.
- `dest` se interpreta: None -> cwd; directorio EXISTENTE -> dentro; ruta de archivo
-> esa ruta. Un directorio que aún no existe se trata como ruta de archivo: créalo
antes (o termina la ruta en separador).
@@ -0,0 +1,162 @@
"""Localiza y descarga el output de audio de un workflow ComfyUI a disco.
Hermana de comfyui_fetch_output_video / comfyui_fetch_output_image / _mesh, pero
para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced).
Esos nodos exponen su salida en GET /history/{prompt_id} bajo la clave "audio"
como lista de items {filename, subfolder, type}. Esta funcion localiza el primer
archivo con extension de audio (.flac/.wav/.mp3/.opus/.ogg/.m4a), lo baja via
GET /view a disco y, opcionalmente, lo escribe en `dest`.
Impura: red (HTTP GET a /history y /view) + escritura en disco. Solo stdlib.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
# Extensiones de audio que producen los nodos SaveAudio* de ComfyUI.
_AUDIO_EXTS = (".flac", ".wav", ".mp3", ".opus", ".ogg", ".m4a")
# Claves de output preferentes para audio (se inspeccionan primero).
_AUDIO_KEYS = ("audio", "audios")
def _is_audio_item(item: dict) -> bool:
"""True si el item de output apunta a un archivo de audio (por extension)."""
fn = (item.get("filename") or "").lower()
return fn.endswith(_AUDIO_EXTS)
def _find_audio_output(outputs: dict) -> dict | None:
"""Busca en los outputs de /history el primer archivo de audio.
Hace dos pasadas: primero en la clave preferente "audio" (la que usan los
nodos SaveAudio*), luego en cualquier clave por si un nodo lo expone bajo
otro nombre. Devuelve {filename, subfolder, type} o None.
"""
for prefer in (True, False):
for node_out in outputs.values():
if not isinstance(node_out, dict):
continue
for key, items in node_out.items():
if prefer and key not in _AUDIO_KEYS:
continue
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and _is_audio_item(item):
return {
"filename": item.get("filename", ""),
"subfolder": item.get("subfolder", ""),
"type": item.get("type", "output"),
}
return None
def _resolve_dest(dest: str | None, filename: str) -> str:
"""Resuelve la ruta local destino a partir de `dest` y el basename remoto."""
base = os.path.basename(filename)
if dest is None:
return os.path.join(os.getcwd(), base)
expanded = os.path.expanduser(dest)
if os.path.isdir(expanded) or expanded.endswith(os.sep):
return os.path.join(expanded, base)
return expanded
def comfyui_fetch_output_audio(
prompt_id: str,
*,
server: str = "127.0.0.1:8188",
dest: str | None = None,
outputs: dict | None = None,
timeout: float = 120.0,
) -> dict:
"""Descarga el audio de un prompt ComfyUI ya ejecutado a disco local.
Args:
prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo
nodo de audio (SaveAudio/SaveAudioMP3/...) ya termino (usa
comfyui_wait_result antes si dudas). Se ignora si se pasa `outputs`.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest: ruta destino. Si None, escribe el basename del audio en el cwd.
Si es un directorio (o termina en separador), escribe el basename
dentro. Si es una ruta de archivo, escribe ahi. keyword-only.
outputs: dict de outputs ya obtenido (el que devuelve comfyui_wait_result).
Si se pasa, se busca el audio ahi y NO se consulta /history (evita una
peticion de red extra justo despues de esperar). keyword-only.
timeout: timeout de cada peticion HTTP en segundos. keyword-only.
Returns:
dict {ok, path, format, bytes, error}. path = ruta local del archivo de
audio guardado; format = extension sin punto (ej. "flac" o "mp3"); bytes =
tamano descargado. Si falla, ok=False y error explica (sin audio en los
outputs, HTTP, conexion o escritura).
"""
# 1. Obtener los outputs: del parametro (sin red) o consultando /history.
if outputs is None:
hist_url = f"http://{server}/history/{prompt_id}"
try:
with urllib.request.urlopen(hist_url, timeout=timeout) as resp:
hist = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:200]
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"HTTP {exc.code} en {hist_url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo conectar a {hist_url}: {exc.reason}"}
except json.JSONDecodeError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"respuesta no es JSON valido desde {hist_url}: {exc}"}
entry = hist.get(prompt_id)
if not entry:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"prompt_id {prompt_id} no esta en /history (¿no termino o se purgo?)"}
outputs = entry.get("outputs", {})
audio = _find_audio_output(outputs or {})
if audio is None:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"sin archivo de audio en los outputs de {prompt_id}"}
# 2. Descargar el archivo via GET /view.
qs = urllib.parse.urlencode({
"filename": audio["filename"],
"subfolder": audio["subfolder"],
"type": audio["type"],
})
view_url = f"http://{server}/view?{qs}"
try:
with urllib.request.urlopen(view_url, timeout=timeout) as resp:
blob = resp.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")[:200]
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"HTTP {exc.code} en {view_url}: {body}"}
except urllib.error.URLError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo conectar a {view_url}: {exc.reason}"}
# 3. Escribir a disco.
out_path = _resolve_dest(dest, audio["filename"])
try:
parent = os.path.dirname(out_path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(out_path, "wb") as f:
f.write(blob)
except OSError as exc:
return {"ok": False, "path": "", "format": "", "bytes": 0,
"error": f"no se pudo escribir en {out_path!r}: {exc}"}
fmt = os.path.splitext(audio["filename"])[1].lstrip(".").lower()
return {"ok": True, "path": out_path, "format": fmt, "bytes": len(blob), "error": ""}
if __name__ == "__main__":
import sys
pid = sys.argv[1] if len(sys.argv) > 1 else "00000000-0000-0000-0000-000000000000"
res = comfyui_fetch_output_audio(pid, dest="/tmp/comfy_audio")
print(json.dumps(res, indent=2))
@@ -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"
---
@@ -5,7 +5,7 @@ lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
signature: "def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict"
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
uses_functions: []
@@ -14,12 +14,12 @@ import urllib.request
import zlib
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict:
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
Args:
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
timeout: timeout HTTP en segundos (solo si es URL).
Returns:
dict {ok, prompt, workflow, format_detected, error}:
+43 -22
View File
@@ -3,10 +3,10 @@ name: comfyui_interrupt_queue
kind: function
lang: py
domain: ml
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
signature: "def comfyui_interrupt_queue(clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y, si clear_pending=True, vacia ademas la cola de pendientes (POST /queue {\"clear\":true}). Consulta GET /queue al final para reportar queue_remaining. Devuelve {ok, interrupted, cleared, queue_remaining, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes salvo clear_pending. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
tags: [comfyui, ml, queue, interrupt, control, http]
uses_functions: []
uses_types: []
@@ -15,12 +15,16 @@ returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: clear_pending
desc: "keyword-only. Si True, ademas de cortar el prompt en ejecucion vacia la cola de pendientes con POST /queue {\"clear\":true}. Default False."
- name: server
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
tested: false
tests: []
test_file_path: ""
desc: "keyword-only. host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
- name: timeout
desc: "keyword-only. Timeout de cada peticion HTTP en segundos (default 10.0)."
output: "dict con ok (bool, True si interrupt + clear (si se pidio) + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), cleared (bool, True si clear_pending y POST /queue {clear:true} respondio; False si no se pidio o fallo), queue_remaining (int, queue_running + queue_pending tras la operacion), error (str, vacio si todo OK)."
tested: true
tests: ["test_interrumpe_sin_vaciar", "test_clear_pending_vacia_cola", "test_clear_pending_cola_vacia_no_rompe", "test_servidor_caido_no_lanza"]
test_file_path: "python/functions/ml/tests/test_comfyui_interrupt_queue.py"
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
---
@@ -31,30 +35,47 @@ import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
# Solo cortar el prompt en ejecucion (los pendientes siguen):
res = comfyui_interrupt_queue()
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
if res["ok"] and res["interrupted"]:
print(f"cortado; pendientes en cola: {res['queue_pending']}")
# {'ok': True, 'interrupted': True, 'cleared': False, 'queue_remaining': 3, 'error': ''}
# Cortar el actual Y vaciar los pendientes de golpe:
res = comfyui_interrupt_queue(clear_pending=True)
# {'ok': True, 'interrupted': True, 'cleared': True, 'queue_remaining': 0, 'error': ''}
if res["ok"]:
print(f"cortado; quedan {res['queue_remaining']} en cola")
```
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
O lanzable directo: `./fn run comfyui_interrupt_queue` · `./fn run comfyui_interrupt_queue --clear`.
## Cuando usarla
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
la prevista, o tras encolar por error un workflow pesado. Con `clear_pending=True`
es el freno de mano completo: corta el actual y borra todo lo encolado en una sola
llamada (sin tener que encadenar `comfyui_queue_manage("clear")` despues). Tras la
operacion `queue_remaining` dice de un vistazo cuanto queda en cola.
## Gotchas
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
+ lee).
- `/interrupt` corta SOLO el prompt en ejecucion; sin `clear_pending` los pendientes
(`queue_pending`) siguen y el siguiente arranca de inmediato. Pasa
`clear_pending=True` para vaciar tambien la cola (POST /queue {"clear": true}).
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
mata. Si la cola esta vacia, tanto el interrupt como el clear son inocuos
(`interrupted=True`/`cleared=True` igual, `queue_remaining=0`).
- `queue_remaining` se lee al FINAL (GET /queue tras interrupt+clear): es
`queue_running + queue_pending`. Justo tras un interrupt sin clear puede ser >0
porque el siguiente pendiente ya arranco.
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
`ok` antes de fiarte de los conteos.
`ok` antes de fiarte de `queue_remaining`.
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
trabajo pesado (esta funcion no lo hace).
trabajo pesado (esta funcion no lo hace; ver el round-trip build -> submit -> wait).
- Para operaciones de cola mas finas (borrar UN prompt por id, contar el historial)
usa `comfyui_queue_manage`; esta funcion se centra en el interrupt + clear masivo.
## Capability growth log
- v1.1.0 (2026-06-28) — anade flag `clear_pending` (vacia la cola en la misma
llamada) + param `timeout`; el output pasa a {ok, interrupted, cleared,
queue_remaining, error} y se anaden tests (mock HTTP local).
+57 -22
View File
@@ -1,38 +1,52 @@
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
"""Interrumpe la generacion en curso de ComfyUI y, opcionalmente, vacia la cola.
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
Funcion impura: hace red (HTTP POST /interrupt, POST /queue, GET /queue). Solo
stdlib (urllib, json).
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo: NO vacia
los pendientes, solo aborta el actual y el siguiente arranca de inmediato. Para
vaciar de golpe los pendientes hay que ademas hacer POST /queue con {"clear": true}
(lo que activa el flag clear_pending). GET /queue se consulta al final para reportar
cuantos trabajos quedan en cola tras la operacion (queue_remaining).
NO lanza excepcion en fallo de red: devuelve un dict de estado {ok: False, error}.
"""
import json
import urllib.error
import urllib.request
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
def comfyui_interrupt_queue(
clear_pending: bool = False,
server: str = "127.0.0.1:8188",
timeout: float = 10.0,
) -> dict:
"""Corta la generacion en curso de ComfyUI y devuelve el estado de la cola.
Args:
clear_pending: si True, ademas de cortar el prompt en ejecucion vacia la
cola de pendientes con POST /queue {"clear": true}. keyword-only.
server: host:port del servidor ComfyUI sin esquema (default
"127.0.0.1:8188").
"127.0.0.1:8188"). keyword-only.
timeout: timeout de cada peticion HTTP en segundos (default 10.0).
keyword-only.
Returns:
dict con:
- ok (bool): True si tanto el interrupt como la lectura de la cola
tuvieron exito.
- ok (bool): True si el interrupt, la lectura de la cola y (si se pidio)
el clear tuvieron exito.
- interrupted (bool): True si el POST /interrupt respondio sin error.
- queue_running (int): numero de prompts ejecutandose ahora mismo.
- queue_pending (int): numero de prompts encolados pendientes.
- cleared (bool): True si clear_pending era True y el POST /queue
{"clear": true} respondio sin error; False si no se pidio o fallo.
- queue_remaining (int): trabajos que quedan en cola tras la operacion
(queue_running + queue_pending segun GET /queue al final).
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
"""
out = {
"ok": False,
"interrupted": False,
"queue_running": 0,
"queue_pending": 0,
"cleared": False,
"queue_remaining": 0,
"error": "",
}
base = f"http://{server}"
@@ -40,19 +54,37 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
try:
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
with urllib.request.urlopen(req, timeout=10.0):
with urllib.request.urlopen(req, timeout=timeout):
out["interrupted"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
return out
# 2. GET /queue: estado actual de la cola tras el interrupt.
# 2. Opcional: POST /queue {"clear": true} para vaciar los pendientes.
if clear_pending:
try:
payload = json.dumps({"clear": True}).encode()
req = urllib.request.Request(
f"{base}/queue",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout):
out["cleared"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
out["error"] = f"clear fallo: no se pudo conectar a {base}/queue: {reason}"
return out
# 3. GET /queue: cuantos trabajos quedan en cola tras la operacion.
try:
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
with urllib.request.urlopen(f"{base}/queue", timeout=timeout) as resp:
data = json.loads(resp.read())
out["queue_running"] = len(data.get("queue_running", []))
out["queue_pending"] = len(data.get("queue_pending", []))
running = len(data.get("queue_running", []))
pending = len(data.get("queue_pending", []))
out["queue_remaining"] = running + pending
out["ok"] = True
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", exc)
@@ -63,9 +95,12 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
if __name__ == "__main__":
res = comfyui_interrupt_queue()
import sys
clear = "--clear" in sys.argv[1:]
res = comfyui_interrupt_queue(clear_pending=clear)
print(
f"ok={res['ok']} interrupted={res['interrupted']} "
f"running={res['queue_running']} pending={res['queue_pending']} "
f"cleared={res['cleared']} queue_remaining={res['queue_remaining']} "
f"error={res['error']!r}"
)
+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"
---
+1 -1
View File
@@ -5,7 +5,7 @@ lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict"
signature: "def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict"
description: "Lista las skills ComfyUI guardadas en la libreria de disco con su metadata de resumen: slug, title, base_workflow, version, score_mean/score_n y nsfw (de provenance.nsfw), mas n_versions. Respeta include_nsfw=False (oculta las NSFW por defecto). Libreria inexistente o vacia -> lista vacia sin error. library_dir default ~/ComfyUI/skills_library."
error_type: error_go_core
tags: [comfyui, comfyui-skill, ml, skill, library]
+2 -3
View File
@@ -28,13 +28,12 @@ def _n_versions(skill_dir):
if f.startswith("v") and f.endswith(".json")])
def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict:
def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict:
"""Lista las skills de la libreria con su metadata de resumen.
Args:
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
include_nsfw: si False (default), oculta las skills con `provenance.nsfw == True`.
keyword-only.
Returns:
dict ``{ok, skills, count, error}`` donde `skills` es una lista de dicts
@@ -28,9 +28,15 @@ params:
- name: limit
desc: "Si > 0, trunca a los primeros N templates tras filtrar y ordenar por nombre."
output: "dict {ok: bool, count: int, package_version: str, templates: list, error: str}. Cada template: {name, category, bundle, version, path, n_nodes, node_types, is_workflow}. Nunca lanza: paquete ausente o interprete no hallado -> ok=False con error legible que indica como instalar (pip install comfyui-workflow-templates)."
tested: false
tests: []
test_file_path: ""
tested: true
tests:
- "_find_comfyui_python: interprete existente se devuelve tal cual"
- "_find_comfyui_python: ruta inexistente cae al fallback (sys.executable)"
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
- "el dict de retorno conserva todas sus claves aun en fallo"
- "golden (skip si no hay ComfyUI con el paquete): catalogo no vacio, cada template con name+bundle"
- "golden (skip si no hay ComfyUI con el paquete): bundle inexistente filtra a lista vacia con ok=True"
test_file_path: "python/functions/ml/tests/test_comfyui_list_templates.py"
file_path: "python/functions/ml/comfyui_list_templates.py"
---
+1 -1
View File
@@ -5,7 +5,7 @@ lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict"
signature: "def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict"
description: "Lee una receta de skill ComfyUI de la libreria de disco: recipe.json (version actual) o un snapshot versions/vN.json. Hermana inversa de comfyui_save_skill; el round-trip save(recipe)->load(slug) devuelve un dict identico. library_dir default ~/ComfyUI/skills_library. Slug, version o archivo inexistente -> {ok:False} sin lanzar."
error_type: error_go_core
tags: [comfyui, comfyui-skill, ml, skill, library]
+3 -3
View File
@@ -36,14 +36,14 @@ def _version_filename(version):
return None
def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict:
def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict:
"""Lee la receta de una skill (version actual o un snapshot concreto).
Args:
slug: slug de la skill (nombre de su carpeta en la libreria).
version: si None, lee `recipe.json` (version actual). Si se pasa (int, "1" o
"v1"), lee el snapshot `versions/vN.json`. keyword-only.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
"v1"), lee el snapshot `versions/vN.json`.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
Returns:
dict ``{ok, recipe, slug, path, version, error}``. En exito ``ok=True`` y `recipe`
@@ -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"
---
+1 -1
View File
@@ -5,7 +5,7 @@ lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict"
signature: "def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict"
description: "Persiste una receta de skill ComfyUI (schema comfyui-skill) en la libreria de disco: valida el schema minimo y escribe <library_dir>/<slug>/recipe.json + un snapshot inmutable versions/vN.json (N incremental) + bitacora growth_log.jsonl + regenera INDEX.md. No muta la receta (round-trip identico con comfyui_load_skill). library_dir default ~/ComfyUI/skills_library. Devuelve dict {ok, slug, path, version_file, n_versions, error}; nunca lanza."
error_type: error_go_core
tags: [comfyui, comfyui-skill, ml, skill, library, persistence]
+2 -2
View File
@@ -91,13 +91,13 @@ def _rewrite_index(lib):
fh.write("\n".join(lines))
def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict:
def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict:
"""Valida y persiste una receta de skill en la libreria de disco.
Args:
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
`base_workflow` y `version` (strings no vacios). No se muta.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
Returns:
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
@@ -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"
---
+92
View File
@@ -0,0 +1,92 @@
---
name: pixeloe_downscale
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def pixeloe_downscale(src_path: str, dst_path: str, *, mode: str = 'contrast', target_size: int = 64, patch_size: int = 16, thickness: int = 2, color_matching: bool = True, no_upscale: bool = True, comfy_python: str | None = None) -> dict"
description: "Downscale contrast-aware (Contrast-Aware Outline Expansion de Kohaku, lib `pixeloe`) que colapsa una ilustracion a un grid de pixel-art pequeno (64 personajes, 32 iconos) conservando contornos/silueta. Es la etapa de downscale del metodo SOTA de pixel-art (report 0215). NO cuantiza la paleta (eso lo hace despues comfyui_pixelize_image). Resuelve el gotcha de que `pixeloe` solo vive en el venv de ComfyUI con un 'bridge' de interprete: si falta en el interprete actual, re-ejecuta su nucleo por subprocess con el python de ComfyUI. No-throw: todo error viaja en `error`. Determinista; impura por I/O de disco + subprocess. Devuelve {ok, out_path, size, mode, target_size, via, error}."
tags: [comfyui, gamedev-2d, pixelart, ml, pixeloe, downscale, contrast-aware, image, bridge]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: src_path
desc: "ruta de la imagen de entrada (PNG/JPG/...). Si no existe -> ok=False con error."
- name: dst_path
desc: "ruta del PNG de salida; se crea el directorio padre si falta."
- name: mode
desc: "algoritmo de downscale de pixeloe: 'contrast' (SOTA, conserva silueta), 'bicubic', 'nearest', 'center' o 'k-centroid'. keyword-only."
- name: target_size
desc: "lado del grid resultante en pixeles (64 para personajes, 32 para iconos). keyword-only."
- name: patch_size
desc: "tamano del patch que pixeloe colapsa por celda del grid. keyword-only."
- name: thickness
desc: "grosor de la expansion de contorno (outline expansion). keyword-only."
- name: color_matching
desc: "corrige el color de cada celda contra el original si True. keyword-only."
- name: no_upscale
desc: "True devuelve el grid real target_size x target_size (lo habitual, para luego cuantizar); False re-escala al tamano original con pixeles duros (preview). keyword-only."
- name: comfy_python
desc: "ruta a un interprete con `pixeloe` para el bridge cuando el actual no la tiene. Si None: COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen escrita), mode (str usado), target_size (int pedido), via ('inproc' si pixeloe estaba en este interprete, 'bridge' si se delego por subprocess) y error (str, vacio si OK). No lanza excepciones."
tested: true
tests: [test_golden_downscale_64_or_clean_degrade, test_edge_target_size_32, test_edge_mode_nearest_no_color_matching, test_error_missing_src_no_throw, test_error_no_interpreter_with_pixeloe]
test_file_path: "python/functions/ml/pixeloe_downscale_test.py"
file_path: "python/functions/ml/pixeloe_downscale.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.pixeloe_downscale import pixeloe_downscale
# Colapsa el render del caballero (1024x1024) a un grid de pixel-art 64x64
# conservando la silueta. NO cuantiza paleta todavia.
res = pixeloe_downscale(
os.path.expanduser("~/ComfyUI/output/pixel_compare/knight_base_00001_.png"),
"/tmp/knight_grid64.png",
mode="contrast", target_size=64, no_upscale=True,
)
# {'ok': True, 'out_path': '/tmp/knight_grid64.png', 'size': [64, 64],
# 'mode': 'contrast', 'target_size': 64, 'via': 'bridge', 'error': ''}
# Despues: dureza de color (cuantizacion) con la funcion hermana.
from ml.comfyui_pixelize_image import comfyui_pixelize_image
comfyui_pixelize_image("/tmp/knight_grid64.png", "/tmp/knight_q16.png",
downscale=1, colors=16, upscale_back=False)
```
## Cuando usarla
Primera etapa del metodo SOTA de pixel-art: cuando ya tienes una ilustracion (render
SDXL/Flux, sprite, foto) y quieres reducirla a un grid de pixel-art chico **sin perder
los contornos** (lo que arruina un resize NEAREST/lanczos normal). Usala **antes** de
la cuantizacion dura de paleta con `comfyui_pixelize_image` (paso de color). `target_size`
64 para personajes, 32 para iconos. Si solo necesitas el resize+cuantizado rapido sin
contornos finos, `comfyui_pixelize_image` sola basta; para el resultado ganador, encadena
`pixeloe_downscale` -> `comfyui_pixelize_image`.
## Gotchas
- **`pixeloe` solo esta en el venv de ComfyUI** (`~/ComfyUI/.venv`), no en el del registry.
La funcion lo resuelve con un *bridge*: si `import pixeloe` falla, re-ejecuta su nucleo
por subprocess con el python de ComfyUI. El campo `via` dice si fue `inproc` o `bridge`.
- **El modulo es `pixeloe.legacy.pixelize`**, no `pixeloe.pixelize` (ruta vieja eliminada).
- **El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba roto** por ese cambio de import;
por eso aqui se llama la lib directa (numpy + PIL, sin cv2).
- **NO cuantiza la paleta**: el resultado conserva muchos colores; la dureza retro la aplica
despues `comfyui_pixelize_image`. No esperes pocos colores en la salida.
- **No-throw**: src inexistente, pixeloe ausente en todos los interpretes, o subprocess
caido -> `ok=False` con `error` explicado, nunca excepcion. El pipeline llamante hace
fallback mirando `ok`.
- Resolucion del interprete del bridge: arg `comfy_python` -> env `COMFY_PYTHON` ->
`~/ComfyUI/.venv/bin/python3` (el primero que exista como archivo).
- `no_upscale=True` (default) devuelve el grid real `target_size x target_size`; con `False`
vuelve al tamano original con pixeles duros (preview), no el grid pequeno.
+322
View File
@@ -0,0 +1,322 @@
"""pixeloe_downscale — downscale contrast-aware a un grid de pixel-art (etapa SOTA).
Colapsa una ilustracion a un grid de pixel-art pequeno (p.ej. 64x64) usando la
libreria `pixeloe` de Kohaku (Contrast-Aware Outline Expansion), el metodo SOTA
para preservar contornos/silueta al reducir. Es la etapa de *downscale* del
metodo ganador de pixel-art (ver report 0215): NO cuantiza la paleta esa dureza
de color la aplica despues otra funcion (`comfyui_pixelize_image`).
Gotcha de entorno (resuelto con un "bridge" de interprete): la lib `pixeloe` solo
esta instalada en el venv de ComfyUI (`~/ComfyUI/.venv`), no en el venv del
registry, y su modulo vive en `pixeloe.legacy.pixelize` (la ruta vieja
`pixeloe.pixelize` ya no existe). Por eso la funcion:
1. Intenta `import pixeloe` en el interprete actual y ejecuta el nucleo directo.
2. Si falta (`ModuleNotFoundError`), re-ejecuta este mismo archivo como subprocess
(`python pixeloe_downscale.py --bridge <json>`) con un interprete que SI la
tenga, parseando la unica linea JSON que ese hijo imprime a stdout.
3. Si no hay ningun interprete con pixeloe, devuelve ok=False (sin excepcion);
el pipeline que la llama hara fallback.
La funcion es no-throw: cualquier error se captura y viaja en el campo `error`.
Determinista; impura solo por la lectura/escritura de disco y el subprocess.
"""
import os
def _resolve_comfy_python(comfy_python):
"""Devuelve el primer interprete candidato que exista como archivo, o None.
Orden: arg comfy_python -> env COMFY_PYTHON -> ~/ComfyUI/.venv/bin/python3.
"""
candidates = []
if comfy_python:
candidates.append(comfy_python)
env = os.environ.get("COMFY_PYTHON")
if env:
candidates.append(env)
candidates.append(os.path.expanduser("~/ComfyUI/.venv/bin/python3"))
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def _run_core(src_path, dst_path, mode, target_size, patch_size, thickness,
color_matching, no_upscale):
"""Nucleo no-throw: requiere `pixeloe` importable EN ESTE interprete.
Lee src como RGB uint8 (numpy + PIL, sin cv2), llama
`pixeloe.legacy.pixelize.pixelize` y guarda el resultado como PNG. Devuelve el
dict de resultado. NO lanza excepciones: las captura en `error`.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "inproc",
"error": "",
}
try:
import numpy as np
from PIL import Image
except Exception as exc: # noqa: BLE001 - degradacion limpia, no relanzar
out["error"] = f"numpy/PIL no disponible en este interprete: {exc}"
return out
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
try:
from pixeloe.legacy.pixelize import pixelize
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo importar pixeloe.legacy.pixelize: {exc}"
return out
try:
img = np.array(Image.open(src_path).convert("RGB")) # HxWx3 uint8
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
return out
try:
res = pixelize(
img,
mode=mode,
target_size=int(target_size),
patch_size=int(patch_size),
thickness=int(thickness),
contrast=1.0,
saturation=1.0,
color_matching=bool(color_matching),
no_upscale=bool(no_upscale),
)
except TypeError as exc:
# Firma de pixelize distinta a la esperada: reseñar, no relanzar.
out["error"] = f"pixelize rechazo los kwargs (firma distinta?): {exc}"
return out
except Exception as exc: # noqa: BLE001
out["error"] = f"pixelize fallo: {exc}"
return out
try:
arr = np.asarray(res)
result_img = Image.fromarray(arr)
dst_dir = os.path.dirname(os.path.abspath(dst_path))
os.makedirs(dst_dir, exist_ok=True)
result_img.save(dst_path)
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
return out
out.update(ok=True, out_path=dst_path, size=list(result_img.size), error="")
return out
def _run_via_bridge(interp, src_path, dst_path, mode, target_size, patch_size,
thickness, color_matching, no_upscale):
"""Ejecuta el nucleo en otro interprete (que tiene pixeloe) via subprocess.
Corre `interp <este_archivo> --bridge <json_args>` y parsea la ultima linea de
stdout que sea JSON valido (pixeloe puede emitir ruido antes). No-throw.
"""
import json
import subprocess
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "bridge",
"error": "",
}
args = {
"src_path": src_path,
"dst_path": dst_path,
"mode": mode,
"target_size": int(target_size),
"patch_size": int(patch_size),
"thickness": int(thickness),
"color_matching": bool(color_matching),
"no_upscale": bool(no_upscale),
}
try:
proc = subprocess.run(
[interp, os.path.abspath(__file__), "--bridge", json.dumps(args)],
capture_output=True,
text=True,
timeout=600,
)
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo el subprocess bridge ({interp}): {exc}"
return out
if proc.returncode != 0:
tail = (proc.stderr or "").strip()[-500:]
out["error"] = f"bridge salio con codigo {proc.returncode}: {tail}"
return out
# Parsea de atras hacia delante la primera linea que sea JSON valido.
parsed = None
for ln in reversed((proc.stdout or "").splitlines()):
ln = ln.strip()
if not ln:
continue
try:
parsed = json.loads(ln)
break
except Exception: # noqa: BLE001 - linea de ruido, sigue probando
continue
if parsed is None:
tail = (proc.stderr or "").strip()[-300:]
out["error"] = f"bridge no produjo salida JSON. stderr: {tail}"
return out
parsed["via"] = "bridge"
return parsed
def pixeloe_downscale(
src_path: str,
dst_path: str,
*,
mode: str = "contrast",
target_size: int = 64,
patch_size: int = 16,
thickness: int = 2,
color_matching: bool = True,
no_upscale: bool = True,
comfy_python: str | None = None,
) -> dict:
"""Downscale contrast-aware de una imagen a un grid de pixel-art (no cuantiza).
Args:
src_path: ruta de la imagen de entrada (PNG/JPG/...).
dst_path: ruta del PNG de salida (se crea el directorio si falta).
mode: algoritmo de downscale de pixeloe: "contrast" (SOTA, conserva
silueta), "bicubic", "nearest", "center" o "k-centroid". keyword-only.
target_size: lado del grid resultante en pixeles (64 personajes, 32
iconos). keyword-only.
patch_size: tamano del patch que pixeloe colapsa por celda. keyword-only.
thickness: grosor de la expansion de contorno (outline). keyword-only.
color_matching: corrige el color de cada celda contra el original si True.
keyword-only.
no_upscale: True devuelve el grid real target_size x target_size (lo
habitual para luego cuantizar); False re-escala al tamano original con
pixeles duros (preview). keyword-only.
comfy_python: ruta a un interprete con `pixeloe` para el bridge cuando el
actual no la tiene. Si None, se prueba COMFY_PYTHON y luego
~/ComfyUI/.venv/bin/python3. keyword-only.
Returns:
dict con:
- ok (bool): True si se hizo el downscale y se guardo el PNG.
- out_path (str): ruta del PNG generado.
- size (list[int]): [w, h] de la imagen escrita.
- mode (str): modo de downscale usado.
- target_size (int): lado del grid pedido.
- via (str): "inproc" si pixeloe estaba en este interprete, "bridge" si se
delego a otro interprete por subprocess.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "",
"error": "",
}
try:
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
# 1. pixeloe disponible en el interprete actual -> nucleo directo.
has_local = True
try:
import pixeloe # noqa: F401
except ModuleNotFoundError:
has_local = False
except Exception: # noqa: BLE001 - pixeloe presente pero roto -> bridge
has_local = False
if has_local:
res = _run_core(
src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
res["via"] = "inproc"
return res
# 2. Bridge a un interprete que tenga pixeloe.
interp = _resolve_comfy_python(comfy_python)
if interp is None:
out["error"] = (
"pixeloe no disponible: no se encontro ningun interprete con "
"pixeloe (pasa comfy_python, define COMFY_PYTHON, o instala "
"~/ComfyUI/.venv)"
)
return out
return _run_via_bridge(
interp, src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
except Exception as exc: # noqa: BLE001 - contrato no-throw
out["error"] = f"error inesperado: {exc}"
return out
if __name__ == "__main__":
import json
import sys
if "--bridge" in sys.argv:
# Modo bridge: ejecuta el nucleo y emite UNA linea JSON a stdout.
_idx = sys.argv.index("--bridge")
_payload = sys.argv[_idx + 1] if len(sys.argv) > _idx + 1 else "{}"
try:
_a = json.loads(_payload)
except Exception as _exc: # noqa: BLE001
print(json.dumps({
"ok": False, "out_path": "", "size": [0, 0], "mode": "",
"target_size": 0, "via": "inproc",
"error": f"payload --bridge invalido: {_exc}",
}))
sys.exit(0)
_res = _run_core(
_a.get("src_path", ""),
_a.get("dst_path", ""),
_a.get("mode", "contrast"),
_a.get("target_size", 64),
_a.get("patch_size", 16),
_a.get("thickness", 2),
_a.get("color_matching", True),
_a.get("no_upscale", True),
)
print(json.dumps(_res))
sys.exit(0)
# Modo CLI normal.
if len(sys.argv) < 3:
print("uso: pixeloe_downscale.py <src> <dst> [target_size] [mode]",
file=sys.stderr)
sys.exit(2)
_src, _dst = sys.argv[1], sys.argv[2]
_ts = int(sys.argv[3]) if len(sys.argv) > 3 else 64
_md = sys.argv[4] if len(sys.argv) > 4 else "contrast"
print(json.dumps(pixeloe_downscale(_src, _dst, target_size=_ts, mode=_md),
indent=2))
@@ -0,0 +1,122 @@
"""Tests de pixeloe_downscale — tolerantes al entorno.
El venv del registry NO trae `pixeloe`, asi que estas pruebas ejercitan el
"bridge" de interprete (subprocess al python de ComfyUI, que si la tiene). Si
tampoco hay ningun interprete con pixeloe disponible, la funcion debe degradar
limpiamente: ok=False con error no vacio y SIN lanzar excepcion.
Por eso cada test PASA en los dos escenarios:
- pixeloe disponible (inproc o via bridge): assert sobre el resultado real.
- pixeloe ausente en todos lados: assert sobre la degradacion no-throw.
Asi la suite es verde tanto en este PC (ComfyUI presente) como en uno sin ComfyUI,
y el contrato "no-throw" queda cubierto en ambos.
"""
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.pixeloe_downscale import pixeloe_downscale # noqa: E402
def _shapes_png(path, w=256, h=256):
"""PNG 256x256 RGB con un gradiente + formas (contraste con silueta clara)."""
yy, xx = np.mgrid[0:h, 0:w]
arr = np.zeros((h, w, 3), dtype=np.uint8)
arr[..., 0] = (xx * 255 // max(1, w - 1)).astype(np.uint8) # gradiente rojo
arr[..., 1] = (yy * 255 // max(1, h - 1)).astype(np.uint8) # gradiente verde
# Bloque azul central: borde duro para que el modo "contrast" tenga silueta.
arr[h // 4:3 * h // 4, w // 4:3 * w // 4, 2] = 255
Image.fromarray(arr, "RGB").save(path)
return path
def test_golden_downscale_64_or_clean_degrade(tmp_path):
"""Golden: 256x256 -> grid 64x64 (no_upscale). Si pixeloe no esta -> ok=False limpio."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid64.png")
res = pixeloe_downscale(src, dst, target_size=64, no_upscale=True)
assert isinstance(res, dict)
if res["ok"]:
assert os.path.isfile(dst)
assert res["size"] == [64, 64] # no_upscale=True -> grid real
assert res["error"] == ""
assert res["via"] in ("inproc", "bridge")
assert res["mode"] == "contrast"
assert res["target_size"] == 64
else:
# Degradacion limpia: sin pixeloe en ningun interprete.
assert res["error"] != ""
assert res["via"] in ("", "bridge", "inproc")
def test_edge_target_size_32(tmp_path):
"""Edge: grid de 32 (iconos). size==[32,32] cuando pixeloe esta presente."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid32.png")
res = pixeloe_downscale(src, dst, target_size=32, no_upscale=True)
if res["ok"]:
assert res["size"] == [32, 32]
assert res["target_size"] == 32
assert os.path.isfile(dst)
else:
assert res["error"] != ""
def test_edge_mode_nearest_no_color_matching(tmp_path):
"""Edge: otro modo + color_matching off; debe seguir produciendo el grid o degradar."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "near.png")
res = pixeloe_downscale(
src, dst, mode="nearest", target_size=64,
color_matching=False, no_upscale=True,
)
assert isinstance(res, dict)
if res["ok"]:
assert res["mode"] == "nearest"
assert res["size"] == [64, 64]
else:
assert res["error"] != ""
def test_error_missing_src_no_throw(tmp_path):
"""Error path: src inexistente -> ok=False, error explica, sin excepcion."""
res = pixeloe_downscale(
str(tmp_path / "nope.png"), str(tmp_path / "o.png"), target_size=64,
)
assert res["ok"] is False
assert "no existe" in res["error"]
assert res["size"] == [0, 0]
def test_error_no_interpreter_with_pixeloe(tmp_path):
"""Error path: forzar comfy_python invalido cuando el actual no tiene pixeloe.
Si el interprete que corre el test YA tiene pixeloe (inproc), el comfy_python
invalido se ignora y la llamada puede salir ok=True; el test sigue siendo
valido (no-throw). Si NO lo tiene, no hay ningun interprete con pixeloe y debe
devolver ok=False con error, nunca lanzar.
"""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "o.png")
res = pixeloe_downscale(
src, dst, target_size=64, comfy_python="/no/such/python-interpreter",
)
assert isinstance(res, dict)
try:
import pixeloe # noqa: F401
has_local = True
except Exception: # noqa: BLE001
has_local = False
if has_local:
# pixeloe en el interprete del test -> ruta inproc, comfy_python ignorado.
assert res["ok"] is True
else:
# comfy_python invalido + env vacio: si ~/ComfyUI/.venv existe, puede
# bridgear y salir ok; si no, ok=False con error. Ambos no-throw.
assert res["ok"] in (True, False)
if not res["ok"]:
assert res["error"] != ""
@@ -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,90 @@
"""Tests de estructura para comfyui_build_audio_workflow (funcion pura, ACE-Step)."""
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_build_audio_workflow import comfyui_build_audio_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_y_nodos_acestep():
wf = comfyui_build_audio_workflow(
"AUDIO_ace_step_v1_3.5b.safetensors", "retro coin sfx"
)
assert_api_format(wf)
cts = class_types(wf)
for ct in (
"CheckpointLoaderSimple",
"TextEncodeAceStepAudio",
"ConditioningZeroOut",
"EmptyAceStepLatentAudio",
"ModelSamplingSD3",
"KSampler",
"VAEDecodeAudio",
"SaveAudio",
):
assert ct in cts, f"falta nodo {ct}"
assert len(wf) == 8
def test_ckpt_y_prompt_reflejados():
wf = comfyui_build_audio_workflow("AUDIO_x.safetensors", "magic spell whoosh")
assert node_by_ct(wf, "CheckpointLoaderSimple")["inputs"]["ckpt_name"] == "AUDIO_x.safetensors"
enc = node_by_ct(wf, "TextEncodeAceStepAudio")
assert enc["inputs"]["tags"] == "magic spell whoosh"
assert enc["inputs"]["lyrics"] == ""
def test_cableado_ksampler():
wf = comfyui_build_audio_workflow("AUDIO_x.safetensors", "p")
ks = node_by_ct(wf, "KSampler")["inputs"]
# model viene de ModelSamplingSD3 ("11"), no del checkpoint directo
assert ks["model"] == ["11", 0]
assert ks["positive"] == ["6", 0]
# negative pasa por ConditioningZeroOut ("10")
assert ks["negative"] == ["10", 0]
assert ks["latent_image"] == ["5", 0]
assert ks["denoise"] == 1.0
# ModelSamplingSD3 toma el MODEL del checkpoint
assert node_by_ct(wf, "ModelSamplingSD3")["inputs"]["model"] == ["4", 0]
# VAEDecodeAudio usa el VAE del checkpoint
assert node_by_ct(wf, "VAEDecodeAudio")["inputs"]["vae"] == ["4", 2]
# ConditioningZeroOut deriva del positive
assert node_by_ct(wf, "ConditioningZeroOut")["inputs"]["conditioning"] == ["6", 0]
def test_edge_seconds_y_seed_variables():
wf_a = comfyui_build_audio_workflow("c", "p", seconds=4.0, seed=42)
wf_b = comfyui_build_audio_workflow("c", "p", seconds=8.0, seed=99)
assert node_by_ct(wf_a, "EmptyAceStepLatentAudio")["inputs"]["seconds"] == 4.0
assert node_by_ct(wf_b, "EmptyAceStepLatentAudio")["inputs"]["seconds"] == 8.0
assert node_by_ct(wf_a, "KSampler")["inputs"]["seed"] == 42
assert node_by_ct(wf_b, "KSampler")["inputs"]["seed"] == 99
def test_params_reflejados():
wf = comfyui_build_audio_workflow(
"c", "p",
lyrics="la la la", steps=30, cfg=4.0, sampler_name="dpmpp_2m",
scheduler="karras", shift=3.5, lyrics_strength=0.7,
filename_prefix="audio/mio",
)
enc = node_by_ct(wf, "TextEncodeAceStepAudio")["inputs"]
assert enc["lyrics"] == "la la la"
assert enc["lyrics_strength"] == 0.7
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["steps"] == 30
assert ks["cfg"] == 4.0
assert ks["sampler_name"] == "dpmpp_2m"
assert ks["scheduler"] == "karras"
assert node_by_ct(wf, "ModelSamplingSD3")["inputs"]["shift"] == 3.5
assert node_by_ct(wf, "SaveAudio")["inputs"]["filename_prefix"] == "audio/mio"
def test_determinismo():
a = comfyui_build_audio_workflow("c", "p", seconds=5.0, seed=7)
b = comfyui_build_audio_workflow("c", "p", seconds=5.0, seed=7)
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
@@ -3,6 +3,8 @@
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__), "..", ".."))
@@ -10,35 +12,54 @@ from ml.comfyui_build_flux_workflow import comfyui_build_flux_workflow
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct
def test_estructura_y_class_types():
wf = comfyui_build_flux_workflow("POS")
_BASE_CTS = {
"UNETLoader",
"DualCLIPLoader",
"VAELoader",
"EmptyLatentImage",
"CLIPTextEncode",
"RandomNoise",
"KSamplerSelect",
"BasicScheduler",
"BasicGuider",
"SamplerCustomAdvanced",
"VAEDecode",
"SaveImage",
}
def test_schnell_class_types_sin_fluxguidance():
wf = comfyui_build_flux_workflow("POS", variant="schnell")
assert_api_format(wf)
assert class_types(wf) == {
"UNETLoader",
"DualCLIPLoader",
"VAELoader",
"CLIPTextEncode",
"FluxGuidance",
"EmptySD3LatentImage",
"KSampler",
"VAEDecode",
"SaveImage",
}
# schnell usa el camino custom-advanced y NO incluye FluxGuidance.
assert class_types(wf) == _BASE_CTS
# BasicGuider consume el CLIPTextEncode positivo directo.
assert node_by_ct(wf, "BasicGuider")["inputs"]["conditioning"] == ["6", 0]
def test_dev_class_types_con_fluxguidance():
wf = comfyui_build_flux_workflow("POS", variant="dev", guidance=2.5)
assert_api_format(wf)
assert class_types(wf) == _BASE_CTS | {"FluxGuidance"}
fg = node_by_ct(wf, "FluxGuidance")["inputs"]
assert fg["guidance"] == 2.5
assert fg["conditioning"] == ["6", 0] # FluxGuidance aplica sobre el positivo
# BasicGuider consume la salida de FluxGuidance, no el CLIPTextEncode directo.
assert node_by_ct(wf, "BasicGuider")["inputs"]["conditioning"] == ["21", 0]
def test_loaders_separados_de_flux():
# Flux carga UNET + dos text encoders + VAE por separado (no checkpoint unico).
wf = comfyui_build_flux_workflow(
"POS",
unet="IMG_flux1-schnell-fp8-e4m3fn.safetensors",
clip_l="clip_l.safetensors",
t5xxl="t5xxl_fp8_e4m3fn_scaled.safetensors",
vae="ae.safetensors",
weight_dtype="fp8_e4m3fn",
variant="schnell",
clip_l_name="clip_l.safetensors",
t5xxl_name="t5xxl_fp8_e4m3fn_scaled.safetensors",
vae_name="ae.safetensors",
)
unet = node_by_ct(wf, "UNETLoader")["inputs"]
assert unet["unet_name"] == "IMG_flux1-schnell-fp8-e4m3fn.safetensors"
assert unet["weight_dtype"] == "fp8_e4m3fn"
assert unet["weight_dtype"] == "default"
dual = node_by_ct(wf, "DualCLIPLoader")["inputs"]
assert dual["type"] == "flux"
assert dual["clip_name1"] == "t5xxl_fp8_e4m3fn_scaled.safetensors"
@@ -46,25 +67,36 @@ def test_loaders_separados_de_flux():
assert node_by_ct(wf, "VAELoader")["inputs"]["vae_name"] == "ae.safetensors"
def test_guidance_y_cfg_de_flux():
# La guia va por FluxGuidance; el cfg del KSampler se fija a 1.0 (schnell).
wf = comfyui_build_flux_workflow("POS", guidance=2.5)
assert node_by_ct(wf, "FluxGuidance")["inputs"]["guidance"] == 2.5
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["cfg"] == 1.0
# KSampler positive consume la salida de FluxGuidance, no la del CLIPTextEncode directo.
assert ks["positive"] == ["13", 0]
def test_unet_default_por_variante():
schnell = comfyui_build_flux_workflow("POS", variant="schnell")
dev = comfyui_build_flux_workflow("POS", variant="dev")
assert (
node_by_ct(schnell, "UNETLoader")["inputs"]["unet_name"]
== "IMG_flux1-schnell-fp8-e4m3fn.safetensors"
)
assert (
node_by_ct(dev, "UNETLoader")["inputs"]["unet_name"]
== "IMG_flux1-dev-fp8-e4m3fn.safetensors"
)
def test_steps_default_por_variante():
schnell = comfyui_build_flux_workflow("POS", variant="schnell")
dev = comfyui_build_flux_workflow("POS", variant="dev")
assert node_by_ct(schnell, "BasicScheduler")["inputs"]["steps"] == 4
assert node_by_ct(dev, "BasicScheduler")["inputs"]["steps"] == 20
# steps explicito gana al default.
custom = comfyui_build_flux_workflow("POS", variant="schnell", steps=6)
assert node_by_ct(custom, "BasicScheduler")["inputs"]["steps"] == 6
def test_params_se_reflejan_en_los_nodos():
wf = comfyui_build_flux_workflow("POS", width=768, height=512, steps=8, seed=123)
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["seed"] == 123
assert ks["steps"] == 8
lat = node_by_ct(wf, "EmptySD3LatentImage")["inputs"]
wf = comfyui_build_flux_workflow(
"POS", variant="schnell", width=768, height=512, seed=123
)
assert node_by_ct(wf, "RandomNoise")["inputs"]["noise_seed"] == 123
lat = node_by_ct(wf, "EmptyLatentImage")["inputs"]
assert lat["width"] == 768 and lat["height"] == 512
pos = node_by_ct(wf, "FluxGuidance")["inputs"]["conditioning"]
assert pos == ["6", 0] # FluxGuidance aplica sobre el CLIPTextEncode positivo
def test_filename_prefix_en_saveimage():
@@ -72,8 +104,36 @@ def test_filename_prefix_en_saveimage():
assert node_by_ct(wf, "SaveImage")["inputs"]["filename_prefix"] == "demo_flux"
def test_variant_invalido_lanza_valueerror():
with pytest.raises(ValueError):
comfyui_build_flux_workflow("POS", variant="turbo")
def test_available_valida_modelos_faltantes():
# Si se pasa 'available' y un modelo elegido no esta, lanza FileNotFoundError
# con el nombre que falta (error path: no crashea opaco).
available = {
"unet": ["otro_modelo.safetensors"], # el schnell por defecto NO esta
"clip": ["clip_l.safetensors", "t5xxl_fp8_e4m3fn_scaled.safetensors"],
"vae": ["ae.safetensors"],
}
with pytest.raises(FileNotFoundError) as exc:
comfyui_build_flux_workflow("POS", variant="schnell", available=available)
assert "IMG_flux1-schnell-fp8-e4m3fn.safetensors" in str(exc.value)
def test_available_ok_no_lanza():
available = {
"unet": ["IMG_flux1-schnell-fp8-e4m3fn.safetensors"],
"clip": ["clip_l.safetensors", "t5xxl_fp8_e4m3fn_scaled.safetensors"],
"vae": ["ae.safetensors"],
}
wf = comfyui_build_flux_workflow("POS", variant="schnell", available=available)
assert_api_format(wf)
def test_determinista():
# Builder puro: misma entrada -> mismo dict (sin red, seed fijo, sin estado).
a = comfyui_build_flux_workflow("POS", seed=123)
b = comfyui_build_flux_workflow("POS", seed=123)
a = comfyui_build_flux_workflow("POS", variant="dev", seed=123)
b = comfyui_build_flux_workflow("POS", variant="dev", seed=123)
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,86 @@
"""Tests para comfyui_extract_template.
Cubre, sin tocar red ni GPU:
- El camino de error legible cuando el paquete `comfyui-workflow-templates` no
esta instalado: subprocess local contra el python del venv del registry (que no
lo tiene) -> `ok=False` con mensaje accionable, sin lanzar.
- El contrato del dict de retorno (claves presentes, nombre preservado) aun en
fallo.
El golden path (extraer un template real con sus class_types) y el error
'template inexistente -> sugerencias' solo se ejecutan si hay un ComfyUI con el
paquete instalado; si no, se omiten con `pytest.skip`. Nunca dependen de GPU ni
de un servidor ComfyUI vivo (la conversion to_api, que si necesita servidor, no
se ejercita aqui).
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_extract_template import _find_comfyui_python, comfyui_extract_template
_PKG = "comfyui_workflow_templates_core"
_RET_KEYS = {
"ok", "name", "format", "class_types", "has_subgraphs", "n_nodes",
"graph", "api_workflow", "api_error", "bundle", "version", "assets", "error",
}
def _python_con_paquete():
"""Devuelve un interprete que importa el paquete, o None (para omitir el golden)."""
py = _find_comfyui_python(None)
if not py:
return None
r = subprocess.run([py, "-c", f"import {_PKG}"], capture_output=True)
return py if r.returncode == 0 else None
def test_extract_sin_paquete_error_legible():
# El venv del registry no tiene el paquete -> ok=False con error que lo menciona.
res = comfyui_extract_template("image_sdxl", comfyui_python=sys.executable)
assert res["ok"] is False
assert res["graph"] == {}
assert res["class_types"] == []
assert "comfyui-workflow-templates" in res["error"]
def test_extract_preserva_nombre_y_claves():
# El nombre pedido se preserva y el dict trae siempre todas sus claves.
res = comfyui_extract_template("cualquier_nombre", comfyui_python=sys.executable)
assert res["name"] == "cualquier_nombre"
assert _RET_KEYS <= set(res)
def test_extract_golden_template_real():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
# Toma el primer template real del catalogo y extraelo (to_api=False: sin servidor).
from comfyui_list_templates import comfyui_list_templates
cat = comfyui_list_templates(comfyui_python=py, with_nodes=False, limit=1)
assert cat["ok"] and cat["count"] >= 1
name = cat["templates"][0]["name"]
res = comfyui_extract_template(name, comfyui_python=py)
assert res["ok"] is True
assert res["name"] == name
assert isinstance(res["graph"], dict) and res["graph"]
assert len(res["class_types"]) > 0
assert res["format"] in ("ui_graph", "api")
def test_extract_nombre_inexistente_error_con_sugerencias():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
res = comfyui_extract_template(
"zzz_template_que_no_existe_jamas", comfyui_python=py
)
assert res["ok"] is False
assert "no existe" in res["error"]
@@ -0,0 +1,50 @@
"""Tests de localizacion de output para comfyui_fetch_output_audio.
Solo cubren la logica pura de busqueda (_is_audio_item / _find_audio_output): no
tocan red ni disco. La descarga real via HTTP se prueba en el flujo e2e con el
servidor ComfyUI vivo.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_fetch_output_audio import _find_audio_output, _is_audio_item
def test_is_audio_item_por_extension():
assert _is_audio_item({"filename": "comfy_audio_00001_.flac"})
assert _is_audio_item({"filename": "x.mp3"})
assert _is_audio_item({"filename": "x.WAV"})
assert not _is_audio_item({"filename": "x.png"})
assert not _is_audio_item({"filename": ""})
def test_find_saveaudio_flac_bajo_audio():
outputs = {
"9": {"audio": [{"filename": "comfy_audio_00001_.flac",
"subfolder": "audio", "type": "output"}]}
}
got = _find_audio_output(outputs)
assert got == {"filename": "comfy_audio_00001_.flac",
"subfolder": "audio", "type": "output"}
def test_find_saveaudiomp3_bajo_audio():
outputs = {"12": {"audio": [{"filename": "track.mp3", "subfolder": "", "type": "output"}]}}
assert _find_audio_output(outputs)["filename"] == "track.mp3"
def test_find_prioriza_clave_audio():
# Un nodo deja un png bajo "images" y otro un flac bajo "audio": gana el audio.
outputs = {
"9": {"images": [{"filename": "preview.png", "subfolder": "", "type": "output"}]},
"10": {"audio": [{"filename": "out.flac", "subfolder": "", "type": "output"}]},
}
assert _find_audio_output(outputs)["filename"] == "out.flac"
def test_find_sin_audio_devuelve_none():
outputs = {"9": {"images": [{"filename": "preview.png", "subfolder": "", "type": "output"}]}}
assert _find_audio_output(outputs) is None
assert _find_audio_output({}) is None
@@ -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,149 @@
"""Tests de comfyui_interrupt_queue contra un servidor ComfyUI simulado.
La funcion es pura I/O (HTTP), asi que levantamos un http.server local que imita
los endpoints relevantes de ComfyUI (/interrupt, /queue) y verificamos:
- Golden: interrupt sin clear corta el actual pero NO vacia los pendientes.
- Edge: clear_pending=True vacia la cola (queue_remaining=0).
- Edge: clear_pending=True con la cola ya vacia no rompe.
- Error: si el servidor no responde, devuelve {ok:False, error} sin lanzar.
"""
import http.server
import json
import os
import socket
import sys
import threading
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
class _FakeComfyHandler(http.server.BaseHTTPRequestHandler):
"""Imita ComfyUI: estado de cola mutable compartido via la clase del server."""
def log_message(self, *args): # silenciar el log del servidor en los tests
pass
def _send_json(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
st = self.server.state
if self.path == "/interrupt":
st["running"] = [] # interrupt corta el prompt en ejecucion
self._send_json({})
return
if self.path == "/queue":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
body = json.loads(raw or b"{}")
if body.get("clear"):
st["pending"] = [] # clear vacia los pendientes
elif "delete" in body:
st["pending"] = [
p for p in st["pending"] if p not in body["delete"]
]
self._send_json({})
return
self._send_json({"error": "not found"}, code=404)
def do_GET(self):
st = self.server.state
if self.path == "/queue":
self._send_json(
{
"queue_running": st["running"],
"queue_pending": st["pending"],
}
)
return
self._send_json({"error": "not found"}, code=404)
def _start_fake_server(running, pending):
"""Levanta el servidor fake en un puerto efimero. Devuelve (server, addr, thread)."""
server = http.server.HTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
server.state = {"running": list(running), "pending": list(pending)}
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
return server, f"{host}:{port}", thread
def _free_port():
"""Reserva y libera un puerto para garantizar que NADA escucha ahi (error path)."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def test_interrumpe_sin_vaciar():
# Golden: 1 ejecutandose + 2 pendientes; interrupt corta el actual, pendientes siguen.
server, addr, _ = _start_fake_server(running=["r1"], pending=["p1", "p2"])
try:
res = comfyui_interrupt_queue(server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is False
# running cortado (0) + 2 pendientes que siguen = 2 restantes.
assert res["queue_remaining"] == 2
assert res["error"] == ""
def test_clear_pending_vacia_cola():
# Edge: clear_pending vacia los pendientes -> queue_remaining 0.
server, addr, _ = _start_fake_server(running=["r1"], pending=["p1", "p2", "p3"])
try:
res = comfyui_interrupt_queue(clear_pending=True, server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is True
assert res["queue_remaining"] == 0
assert res["error"] == ""
def test_clear_pending_cola_vacia_no_rompe():
# Edge: clear_pending con la cola ya vacia es inocuo, no rompe.
server, addr, _ = _start_fake_server(running=[], pending=[])
try:
res = comfyui_interrupt_queue(clear_pending=True, server=addr)
finally:
server.shutdown()
assert res["ok"] is True
assert res["interrupted"] is True
assert res["cleared"] is True
assert res["queue_remaining"] == 0
assert res["error"] == ""
def test_servidor_caido_no_lanza():
# Error: nada escucha en el puerto -> {ok:False, error} sin excepcion cruda.
dead = f"127.0.0.1:{_free_port()}"
res = comfyui_interrupt_queue(server=dead, timeout=1.0)
assert res["ok"] is False
assert res["interrupted"] is False
assert res["error"] != ""
assert "interrupt fallo" in res["error"]
if __name__ == "__main__":
test_interrumpe_sin_vaciar()
test_clear_pending_vacia_cola()
test_clear_pending_cola_vacia_no_rompe()
test_servidor_caido_no_lanza()
print("OK: 4 tests passed")
@@ -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,87 @@
"""Tests para comfyui_list_templates.
Cubre dos cosas sin tocar red ni GPU:
- La localizacion del interprete (`_find_comfyui_python`), que solo consulta el
sistema de ficheros.
- El camino de error legible cuando el paquete `comfyui-workflow-templates` no
esta instalado: se ejecuta un subprocess local contra el python indicado (el
del propio venv del registry, que no tiene el paquete) y se comprueba que la
funcion devuelve `ok=False` con un mensaje accionable, sin lanzar.
El golden path (catalogo de templates no vacio) y un edge de filtrado solo se
ejecutan si hay un ComfyUI con el paquete instalado; si no, se omiten con
`pytest.skip`. Nunca dependen de GPU ni de un servidor ComfyUI vivo.
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from comfyui_list_templates import _find_comfyui_python, comfyui_list_templates
_PKG = "comfyui_workflow_templates_core"
_RET_KEYS = {"ok", "count", "package_version", "templates", "error"}
def _python_con_paquete():
"""Devuelve un interprete que importa el paquete, o None (para omitir el golden)."""
py = _find_comfyui_python(None)
if not py:
return None
r = subprocess.run([py, "-c", f"import {_PKG}"], capture_output=True)
return py if r.returncode == 0 else None
def test_find_comfyui_python_explicit_valido():
# Un interprete que existe en disco se devuelve tal cual.
assert _find_comfyui_python(sys.executable) == sys.executable
def test_find_comfyui_python_inexistente_cae_a_fallback():
# Una ruta inexistente no rompe: cae al siguiente candidato (sys.executable existe).
got = _find_comfyui_python("/ruta/que/no/existe/python")
assert got is not None and os.path.isfile(got)
def test_list_sin_paquete_error_legible():
# El venv del registry no tiene el paquete -> ok=False con error que lo menciona.
res = comfyui_list_templates(comfyui_python=sys.executable)
assert res["ok"] is False
assert res["count"] == 0
assert res["templates"] == []
assert "comfyui-workflow-templates" in res["error"]
def test_list_retorno_tiene_todas_las_claves():
# El contrato del dict de retorno se mantiene aun en fallo.
res = comfyui_list_templates(comfyui_python=sys.executable)
assert _RET_KEYS <= set(res)
def test_list_golden_catalogo_no_vacio():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
res = comfyui_list_templates(comfyui_python=py, with_nodes=False, limit=5)
assert res["ok"] is True
assert res["count"] > 0
assert len(res["templates"]) == res["count"]
# Cada template trae al menos nombre y bundle.
for t in res["templates"]:
assert t.get("name")
assert t.get("bundle")
def test_list_golden_filtro_bundle_inexistente_vacio():
py = _python_con_paquete()
if not py:
pytest.skip("no hay ComfyUI con comfyui-workflow-templates instalado")
# Un bundle que no existe filtra a una lista vacia pero la llamada sigue siendo ok.
res = comfyui_list_templates(comfyui_python=py, bundle="bundle-inexistente-xyz")
assert res["ok"] is True
assert res["count"] == 0
assert res["templates"] == []
@@ -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))
@@ -0,0 +1,133 @@
---
name: comfyui_pixelart_real_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_pixelart_real_oneshot(subject: str, *, size: int = 64, colors: int = 16, engine: str = \"pixeloe\", palette=None, server: str = \"127.0.0.1:8188\", dest_dir: str = \"~/ComfyUI/output\", seed: int = 0, negative: str | None = None, mode: str = \"contrast\", patch_size: int = 16, thickness: int = 2, fill_frame: bool = True, upscale_preview: int = 512, keep_base: bool = True, comfy_python: str | None = None, wait_timeout: float = 300.0, filename_prefix: str = \"pixelart_real\", **gen_kwargs) -> dict"
description: "Pipeline one-shot prompt de texto -> sprite pixel-art REAL (grid duro + paleta limitada) en disco. Materializa el metodo ganador del report 0215: generar a alta-res con SDXL + LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe, sprites) o nearest (tiles), y cuantizacion dura con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher]
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: error_py_core
imports: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
params:
- name: subject
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
- name: size
desc: "Lado del grid final en pixeles. 64 personajes/sprites, 32 iconos/objetos simples. keyword-only."
- name: colors
desc: "Numero de colores de la paleta libre (MEDIANCUT) cuando palette es None. keyword-only."
- name: engine
desc: "'pixeloe' (downscale contrast-aware, sujetos con silueta) o 'nearest' (downscale simple, tiles/texturas). Fallback automatico a nearest si pixeloe falla. keyword-only."
- name: palette
desc: "None (paleta libre a `colors`), nombre builtin ('pico-8', 'nes', 'game-boy') o lista de hex. Una paleta fija ignora `colors`. keyword-only."
- name: server
desc: "host:port del servidor ComfyUI (sin esquema). keyword-only."
- name: dest_dir
desc: "Directorio donde guardar los PNG (se expande ~). keyword-only."
- name: seed
desc: "Semilla del KSampler. keyword-only."
- name: negative
desc: "Prompt negativo; None usa el default de build_pixelart (evita blur/gradientes/anti-alias). keyword-only."
- name: mode
desc: "Modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo con engine='pixeloe'. keyword-only."
- name: patch_size
desc: "Tamano de patch de PixelOE (default 16). keyword-only."
- name: thickness
desc: "Grosor del outline expansion de PixelOE (default 2). keyword-only."
- name: fill_frame
desc: "Si True anade un hint de encuadre al subject para que el sujeto llene el frame (mejor detalle por pixel tras el downscale). keyword-only."
- name: upscale_preview
desc: "Si > 0 escribe ademas un PNG re-escalado nearest a ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva. keyword-only."
- name: keep_base
desc: "Si True conserva el PNG base de alta resolucion; si False lo borra tras pixelizar. keyword-only."
- name: comfy_python
desc: "Ruta al interprete de ComfyUI (con la lib pixeloe); None autodetecta. keyword-only."
- name: wait_timeout
desc: "Segundos maximos esperando al server. keyword-only."
- name: filename_prefix
desc: "Prefijo de los archivos de salida. keyword-only."
- name: gen_kwargs
desc: "Params extra para comfyui_build_pixelart_workflow (width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...). keyword-only (**gen_kwargs)."
output: "dict {ok, out_path, out_path_upscaled, base_path, size, colors_final, engine_used, prompt_id, error}. out_path = PNG final size x size; out_path_upscaled = preview re-escalado; engine_used refleja el fallback (pixeloe->nearest). Si falla, ok=False y error explica en que paso. No-throw."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_pixelart_real_oneshot.py"
---
## Ejemplo
```bash
# Personaje 64px, 16 colores, motor pixeloe (sprites con silueta).
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, side view, game sprite"
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from pipelines.comfyui_pixelart_real_oneshot import comfyui_pixelart_real_oneshot
# (a) Personaje 64px, paleta libre 16 colores, PixelOE contrast.
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, side view, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
dest_dir="~/ComfyUI/output",
)
print(res["out_path"], res["colors_final"], res["engine_used"]) # ~16 colores, pixeloe
# (b) Icono 32px de un item.
res = comfyui_pixelart_real_oneshot(
"pixel art sword icon, single object",
size=32, colors=16, engine="pixeloe", seed=7,
)
# (c) Tile sin silueta -> nearest (mas barato) + paleta fija PICO-8.
res = comfyui_pixelart_real_oneshot(
"pixel art grass texture tile, top down, seamless",
size=64, engine="nearest", palette="pico-8", fill_frame=False,
)
```
## Cuando usarla
Cuando quieres pixel-art **de verdad** (grid duro + paleta limitada, verificable
por conteo de colores), no la salida cruda de la difusion (que parece pixelada
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
llamada hace generar -> downscale -> cuantizar. Usa `engine="pixeloe"` para
personajes/criaturas/iconos con silueta (conserva el contorno) y
`engine="nearest"` para tiles/texturas/fondos sin contorno (mas barato, CPU puro).
64px es el sweet-spot de personajes; 32px solo para iconos/objetos simples.
## Gotchas
- Impuro: requiere el **servidor ComfyUI vivo** en `server` (default
`127.0.0.1:8188`) y los modelos instalados (SDXL Juggernaut + LoRA
`SDXL_pixel-art` + `SDXL_lcm-lora`). Si esta caido, falla en submit con
`ok=False` y el error de conexion (nunca lanza).
- `engine="pixeloe"` necesita la lib `pixeloe`, que vive en el venv de ComfyUI
(no en el del registry). `pixeloe_downscale` hace el puente de interprete
automaticamente; si no la encuentra, el pipeline **cae a `nearest`** y lo
reporta en `engine_used` + `error` (no aborta).
- El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba **roto** por un import
obsoleto (`pixeloe.pixelize` -> ahora `pixeloe.legacy.pixelize`); por eso el
pipeline usa la lib directa via `pixeloe_downscale`, no el nodo del server.
- `dest_dir` es un **directorio** (se crea si no existe). Los nombres de salida
son `<prefix>_<size>px_<engine>_<paleta|qN>.png` y `..._up.png` (preview).
- Una **paleta fija** (`pico-8`/`nes`/`game-boy`/lista hex) ignora `colors` y
puede dar menos colores que `colors` si el sujeto no cubre toda la paleta.
- Encuadre: si el sujeto ocupa poca area del frame, a 64/32px queda diminuto.
`fill_frame=True` (default) empuja al sujeto a llenar el frame; aun asi, para
sprites conviene un subject que pida "full body, centered".
- No reintenta el sampler: para mejor toma, varia `seed`.
## Capability growth log
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
(issue 0087).
@@ -0,0 +1,312 @@
"""comfyui_pixelart_real_oneshot — prompt de texto -> sprite pixel-art REAL en disco.
Pipeline one-shot (issue 0087) que materializa el metodo ganador de la
investigacion (report 0215): la difusion NO sabe pintar pixel-perfect (su salida
tiene decenas de miles de colores y bordes con anti-aliasing pixel-art FALSO),
asi que el pixel-art de verdad es siempre post-proceso en dos ejes: colapsar a un
grid duro y limitar la paleta. El metodo ganador combina:
1. Generar a alta resolucion con el look pixel-art (SDXL Juggernaut + LoRA
SDXL_pixel-art), via comfyui_build_pixelart_workflow.
2. Downscale contrast-aware con PixelOE (pixeloe_downscale): elige el pixel mas
representativo de cada zona y engrosa contornos -> silueta legible. Es lo que
distingue un sprite reconocible de una mancha. Solo para sujetos con silueta
(engine="pixeloe"); para tiles/texturas sin contorno, un downscale nearest
simple basta (engine="nearest") y es mas barato.
3. Cuantizacion dura con comfyui_pixelize_image (downscale=1): clava la paleta
exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy)
sobre el grid ya hecho -> 16 colores exactos + 100% grid duro.
Resultado del combo verificado por PIL: grid duro perfecto + paleta limitada +
outline nitido. Sweet-spot: 64px personajes/sprites, 32px iconos/objetos simples.
Compone funciones del registry, no reescribe su logica:
comfyui_build_pixelart_workflow_py_ml (workflow SDXL + LoRA pixel-art)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco, imagen base)
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
comfyui_pixelize_image_py_ml (cuantizacion dura + nearest fallback)
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco. No-throw: cualquier
fallo se captura y se devuelve en el dict de estado (campo error).
"""
from __future__ import annotations
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_build_pixelart_workflow import comfyui_build_pixelart_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_image import comfyui_pixelize_image
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.pixeloe_downscale import pixeloe_downscale
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
# downscale conserve detalle por pixel (gotcha del report: un sujeto que ocupa el
# 25% del frame queda diminuto a 64px). Solo se anade si no esta ya presente.
_FRAME_HINT = "full body, centered, fills frame, no margins"
def _frame_subject(subject: str, fill_frame: bool) -> str:
"""Anade el hint de encuadre al subject si fill_frame y no esta ya."""
if not fill_frame:
return subject
low = subject.lower()
if "fills frame" in low or "full body" in low or "centered" in low:
return subject
return f"{subject}, {_FRAME_HINT}"
def comfyui_pixelart_real_oneshot(
subject: str,
*,
size: int = 64,
colors: int = 16,
engine: str = "pixeloe",
palette=None,
server: str = "127.0.0.1:8188",
dest_dir: str = "~/ComfyUI/output",
seed: int = 0,
negative: str | None = None,
mode: str = "contrast",
patch_size: int = 16,
thickness: int = 2,
fill_frame: bool = True,
upscale_preview: int = 512,
keep_base: bool = True,
comfy_python: str | None = None,
wait_timeout: float = 300.0,
filename_prefix: str = "pixelart_real",
**gen_kwargs,
) -> dict:
"""Genera un sprite pixel-art REAL desde un prompt de texto, end-to-end.
Args:
subject: prompt positivo (lo que se quiere ver: "pixel art knight, full
body, side view", etc.). No puede estar vacio.
size: lado del grid final en pixeles (64 personajes/sprites, 32 iconos).
keyword-only.
colors: numero de colores de la paleta libre cuando palette es None
(cuantizacion MEDIANCUT). keyword-only.
engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta:
personajes/criaturas/iconos) o "nearest" (downscale nearest simple,
mas barato, para tiles/texturas/fondos sin contorno). Si "pixeloe"
falla o la lib no esta disponible, cae automaticamente a "nearest" y
lo reporta en engine_used. keyword-only.
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
"game-boy") o lista de hex. Una paleta fija ignora `colors`.
keyword-only.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio donde guardar los PNG (se expande ~). keyword-only.
seed: semilla del KSampler. keyword-only.
negative: prompt negativo; None usa el default de build_pixelart
(evita blur/gradientes/anti-alias). keyword-only.
mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid",
"nearest", "center", "bicubic"); solo aplica con engine="pixeloe".
keyword-only.
patch_size: tamano de patch de PixelOE (default 16). keyword-only.
thickness: grosor del outline expansion de PixelOE (default 2).
keyword-only.
fill_frame: si True, anade un hint de encuadre al subject para que el
sujeto llene el frame (mejor detalle por pixel tras el downscale).
keyword-only.
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
keyword-only.
keep_base: si True conserva el PNG base de alta resolucion; si False lo
borra tras pixelizar. keyword-only.
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe); None
autodetecta. keyword-only.
wait_timeout: segundos maximos esperando al server. keyword-only.
filename_prefix: prefijo de los archivos de salida. keyword-only.
**gen_kwargs: params extra para comfyui_build_pixelart_workflow
(width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...).
Returns:
dict con:
- ok (bool): True si se produjo el PNG final pixelizado.
- out_path (str): ruta del PNG final size x size.
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
- size (int): lado real del PNG final.
- colors_final (int): numero de colores distintos en el resultado.
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
- prompt_id (str): id del trabajo en ComfyUI.
- error (str): mensaje de error; vacio si OK.
"""
out = {
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
"size": int(size), "colors_final": 0, "engine_used": engine,
"prompt_id": "", "error": "",
}
if not subject or not subject.strip():
out["error"] = "subject vacio"
return out
if int(size) < 1:
out["error"] = f"size debe ser >= 1, recibido {size!r}"
return out
if engine not in ("pixeloe", "nearest"):
out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')"
return out
dest = os.path.expanduser(dest_dir)
try:
os.makedirs(dest, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear dest_dir {dest!r}: {exc}"
return out
# --- Fase 1: generar la imagen base de alta resolucion (look pixel-art) ---
positive = _frame_subject(subject, fill_frame)
try:
if negative is None:
workflow = comfyui_build_pixelart_workflow(
positive, seed=seed, filename_prefix=f"{filename_prefix}_base",
**gen_kwargs,
)
else:
workflow = comfyui_build_pixelart_workflow(
positive, negative, seed=seed,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
except (ValueError, TypeError) as exc:
out["error"] = f"build workflow fallo: {exc}"
return out
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
out["prompt_id"] = prompt_id
except (RuntimeError, KeyError, OSError) as exc:
out["error"] = f"submit fallo (server {server} responde?): {exc}"
return out
try:
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError, OSError) as exc:
out["error"] = f"wait fallo: {exc}"
return out
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:
out["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
return out
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"):
out["error"] = f"fetch de imagen base fallo: {fetched.get('error')}"
return out
base_path = fetched["path"]
out["base_path"] = base_path
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
engine_used = engine
if engine == "pixeloe":
ds = pixeloe_downscale(
base_path, mid_path, mode=mode, target_size=int(size),
patch_size=patch_size, thickness=thickness, no_upscale=True,
comfy_python=comfy_python,
)
if not ds.get("ok"):
# Fallback limpio: PixelOE no disponible / fallo -> nearest.
engine_used = "nearest"
out["error"] = (
f"pixeloe fallo ({ds.get('error')}); fallback a nearest"
)
if engine_used == "nearest":
# Downscale nearest simple a size x size (PIL en el venv del registry).
try:
from PIL import Image
with Image.open(base_path) as src:
small = src.convert("RGB").resize((int(size), int(size)), Image.NEAREST)
small.save(mid_path)
except (ImportError, OSError) as exc:
out["error"] = f"downscale nearest fallo: {exc}"
return out
if not os.path.isfile(mid_path):
out["error"] = "no se genero la imagen intermedia (mid)"
return out
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
final_tag = palette if isinstance(palette, str) else f"q{colors}"
final_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}.png"
)
quant = comfyui_pixelize_image(
mid_path, final_path, downscale=1, colors=int(colors),
palette=palette, upscale_back=False,
)
if not quant.get("ok"):
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
return out
out["out_path"] = final_path
out["size"] = quant["size"][0] if quant.get("size") else int(size)
out["colors_final"] = quant.get("n_colors_final", 0)
out["engine_used"] = engine_used
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
if int(upscale_preview) > 0:
up_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}_up.png"
)
try:
from PIL import Image
with Image.open(final_path) as fin:
up = fin.convert("RGB").resize(
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
)
up.save(up_path)
out["out_path_upscaled"] = up_path
except (ImportError, OSError) as exc:
# El preview es opcional: no invalida el resultado.
out["out_path_upscaled"] = ""
if not out["error"]:
out["error"] = f"preview upscale fallo (no critico): {exc}"
# Limpieza opcional de la base y del intermedio.
try:
os.remove(mid_path)
except OSError:
pass
if not keep_base:
try:
os.remove(base_path)
out["base_path"] = ""
except OSError:
pass
out["ok"] = True
return out
if __name__ == "__main__":
import json
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, side view, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
dest_dir="/tmp/comfy_pixelart_real",
)
print(json.dumps(res, indent=2))