feat(ml): núcleo subsistema comfyui-skill + ask_llm_vision

Grupo nuevo comfyui-skill: recetas versionadas de generación ComfyUI que
compilan a un workflow cambiando solo el subject.

- comfyui_build_skill_workflow (pura): receta -> workflow API format,
  despacha base (txt2img/flux/sdxl_refiner), sustituye {subject}+triggers,
  encadena loras e inject blocks (facedetailer, hires_fix). SkillWorkflowError tipada.
- comfyui_inject_hires_fix (pura): inyecta 2ª pasada UltimateSDUpscale sobre dict.
- comfyui_save/load/list_skill (impuras): CRUD de la librería en disco con
  versionado por snapshots, round-trip idéntico, filtro NSFW.
- ask_llm_vision (core, claude-direct): pregunta multimodal imagen+texto via
  API directa Anthropic, para puntuar generaciones.
- Página madre docs/capabilities/comfyui-skill.md con schema canónico de recipe.json.

Tests offline: 11 verdes (6 builder + 5 inject_hires_fix). Sin GPU.
This commit is contained in:
agent
2026-06-24 14:35:46 +02:00
parent e8a66f0dad
commit 70d541fca9
15 changed files with 1666 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# ComfyUI Skill — Recetas versionadas de generación reutilizables
Tag: `comfyui-skill`. Grupo para tratar una configuración de generación de ComfyUI como una
**skill**: una receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt +
bloques de post-proceso) que se guarda una vez y se reproduce a un workflow concreto cambiando
solo el *subject*. Es la doctrina del issue 0087 aplicada a la generación de imágenes: el registry
crece **promoviendo configuraciones que funcionan a recetas reutilizables**, no reescribiendo el
grafo de nodos cada vez.
Construye sobre el grupo [`comfyui`](comfyui.md) (los builders puros de workflow y el ciclo
submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
## Qué es una skill
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
```
~/ComfyUI/skills_library/
INDEX.md # índice regenerado de todas las skills
<slug>/
recipe.json # la receta actual
versions/vN.json # snapshot inmutable de cada save (N incremental)
growth_log.jsonl # bitácora append-only de cada save
exports/ # plantillas de workflow exportadas
samples/ # imágenes de muestra
```
### Schema de `recipe.json` (canónico)
```json
{
"schema_version": 1,
"slug": "portrait_cinematic_sdxl",
"version": "1.0.0",
"title": "Retrato cinematográfico SDXL",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216, "denoise": 1.0},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}},
{"type": "hires_fix", "params": {"upscale_by": 1.5, "denoise": 0.4}}],
"score_mean": 0.0, "score_n": 0,
"provenance": {"source": "manual", "nsfw": false},
"export_template_path": "exports/portrait_cinematic_sdxl.template.json"
}
```
`base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`} (las bases que se generan desde un *subject*
de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}.
## Funciones del grupo
| ID | Firma corta | Qué hace | Purity |
|---|---|---|---|
| [comfyui_build_skill_workflow_py_ml](../../python/functions/ml/comfyui_build_skill_workflow.md) | `build_skill_workflow(recipe, subject, *, seed=0) -> dict` | Compila una receta a un workflow en API format: despacha al builder base, sustituye `{subject}` + trigger_words, encadena LoRAs y aplica los blocks en orden. `SkillWorkflowError` si la base es desconocida o requiere imagen. | **pura** |
| [comfyui_inject_hires_fix_py_ml](../../python/functions/ml/comfyui_inject_hires_fix.md) | `comfyui_inject_hires_fix(workflow, *, upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Inyecta una 2ª pasada hires-fix (UpscaleModelLoader + UltimateSDUpscale) sobre un workflow ya construido, repuntando el SaveImage. Versión encadenable-sobre-dict del builder hermano. | **pura** |
| [comfyui_save_skill_py_ml](../../python/functions/ml/comfyui_save_skill.md) | `comfyui_save_skill(recipe, *, library_dir=None) -> dict` | Valida el schema mínimo y escribe `recipe.json` + snapshot `versions/vN.json` + growth_log + INDEX.md. No muta la receta (round-trip con load). | impura |
| [comfyui_load_skill_py_ml](../../python/functions/ml/comfyui_load_skill.md) | `comfyui_load_skill(slug, *, version=None, library_dir=None) -> dict` | Lee `recipe.json` (actual) o un snapshot `versions/vN.json`. Slug/versión inexistente → `{ok:False}` sin lanzar. | impura |
| [comfyui_list_skills_py_ml](../../python/functions/ml/comfyui_list_skills.md) | `comfyui_list_skills(*, library_dir=None, include_nsfw=False) -> dict` | Lista las skills con slug/title/base_workflow/version/score/nsfw/n_versions. Oculta NSFW por defecto. | impura |
| [ask_llm_vision_py_core](../../python/functions/core/ask_llm_vision.md) | `ask_llm_vision(prompt, image_path='', *, image_b64='', media_type='', model='claude-opus-4-8', ...) -> dict` | Pregunta multimodal (imagen + texto) al modelo via API directa de Anthropic (grupo `claude-direct`). Útil para **puntuar** el PNG de una skill y alimentar `score_mean`. | impura |
`build_skill_workflow` compone los builders del grupo [`comfyui`](comfyui.md):
`comfyui_build_txt2img_workflow`, `comfyui_build_flux_workflow`,
`comfyui_build_sdxl_refiner_workflow`, `comfyui_inject_lora`,
`comfyui_build_facedetailer_workflow` y `comfyui_inject_hires_fix`.
## Ejemplo canónico end-to-end (receta → workflow → PNG → score)
Guardar una skill, cargarla, compilarla a un workflow para un sujeto, encolarla y puntuar el
resultado con visión. Requiere el server ComfyUI en `127.0.0.1:8188` y los modelos de la receta
instalados.
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_save_skill import comfyui_save_skill
from ml.comfyui_load_skill import comfyui_load_skill
from ml.comfyui_build_skill_workflow import build_skill_workflow
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from core.ask_llm_vision import ask_llm_vision
# 1. Definir y guardar la skill (una vez).
recipe = {
"schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0",
"title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img",
"checkpoint": "dreamshaper_8.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 28, "cfg": 6.0, "sampler_name": "dpmpp_2m", "scheduler": "karras"},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
"score_mean": 0.0, "score_n": 0, "provenance": {"source": "manual", "nsfw": False},
}
comfyui_save_skill(recipe) # ~/ComfyUI/skills_library/portrait_cinematic_sdxl/
# 2. Cargar + compilar a un workflow para un sujeto concreto.
recipe = comfyui_load_skill("portrait_cinematic_sdxl")["recipe"]
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
# 3. Encolar y esperar el PNG (camino headless del grupo comfyui).
pid = comfyui_submit_workflow(wf)["prompt_id"]
outputs = comfyui_wait_result(pid)["outputs"]
img = comfyui_fetch_output_image(outputs[0]["filename"], dest_dir="/tmp")["path"]
# 4. Puntuar el resultado con visión (alimenta el bucle de scoring de la skill).
verdict = ask_llm_vision(
"Puntúa de 0 a 10 el realismo de este retrato. Responde solo el número.",
image_path=img, model="claude-opus-4-8",
)
print(verdict["text"])
```
El paso "guardar la receta" se hace una sola vez; a partir de ahí cada generación es
`load → build → submit`, cambiando solo el `subject` y la `seed`.
## Fronteras
- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben
estar ya instalados en ComfyUI (`comfyui_download_model`, otro flujo). `build_skill_workflow` es
puro y no valida contra el servidor — usa `comfyui_validate_workflow` antes de encolar si dudas.
- **`base_workflow` solo de texto**: `txt2img`, `flux`, `sdxl_refiner`. Las bases que parten de una
imagen (`img2img`, `inpaint`, `controlnet`) lanzan `SkillWorkflowError`; para esas, monta el
workflow con los builders del grupo `comfyui` directamente.
- **`blocks` soportados**: `facedetailer` y `hires_fix`. Otros post-procesos (IPAdapter,
multi-ControlNet) se añaden creando su función-inyector hermana y registrándola en el dispatcher
de `build_skill_workflow`.
- **El scoring (`score_mean`/`score_n`) no se calcula aquí**: `ask_llm_vision` da el juicio del
modelo sobre una imagen, pero actualizar la receta con el score acumulado es trabajo de otra
pieza (el bucle de scoring) que reescribe la receta y la re-guarda con `comfyui_save_skill`.
- **La librería es metadata local**: vive bajo `~/ComfyUI/skills_library` (no toca el venv ni los
modelos en disco). No tiene repo propio ni se indexa — es estado vivo, como un `operations.db`.
- **Las funciones impuras del grupo** (save/load/list, ask_llm_vision) no llevan unit tests por
diseño (I/O de disco / red); `build_skill_workflow` e `inject_hires_fix` son puras y sí tienen
tests de estructura offline (`python/functions/ml/tests/test_comfyui_build_skill_workflow.py`,
`test_comfyui_inject_hires_fix.py`).
+92
View File
@@ -0,0 +1,92 @@
---
name: ask_llm_vision
kind: function
lang: py
domain: core
version: "1.0.0"
purity: impure
signature: "def ask_llm_vision(prompt: str, image_path: str = '', *, image_b64: str = '', media_type: str = '', model: str = 'claude-opus-4-8', system: str = '', max_tokens: int = 4096, echo: bool = False, token: str = '') -> dict"
description: "Pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic con el token OAuth de Claude Max. Construye un content block [imagen base64, texto] y devuelve dict {ok, text, model, error}. Wrapper sobre stream_anthropic_messages (grupo claude-direct, arranque 0, sin proceso claude). Util para describir/puntuar/clasificar imagenes (p.ej. evaluar una generacion ComfyUI)."
error_type: error_go_core
tags: ["claude-direct", "llm", "anthropic", "vision", "multimodal", "image", "oauth"]
uses_functions:
- stream_anthropic_messages_py_core
uses_types: []
params:
- name: prompt
desc: "Pregunta o instruccion de texto sobre la imagen."
- name: image_path
desc: "Ruta a la imagen en disco; se lee y codifica a base64. El media_type se deduce de la extension si no se pasa. Ignorado si se pasa image_b64."
- name: image_b64
desc: "Alternativa a image_path: la imagen ya en base64 (sin prefijo data:). Requiere media_type explicito. keyword-only."
- name: media_type
desc: "Tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif). Obligatorio con image_b64; deducido del path si se omite. keyword-only."
- name: model
desc: "Id del modelo Anthropic con vision. Default claude-opus-4-8. keyword-only."
- name: system
desc: "System prompt opcional (string vacio = ninguno). keyword-only."
- name: max_tokens
desc: "Maximo de tokens de salida. Default 4096. keyword-only."
- name: echo
desc: "Si True, vuelca el texto a stdout segun llega (streaming). keyword-only."
- name: token
desc: "Token OAuth; si vacio lo carga stream_anthropic_messages automaticamente. keyword-only."
output: "dict {ok, text, model, error}. En exito ok=True y text lleva la respuesta completa; en error ok=False, text vacio y error describe la causa. Nunca lanza excepcion."
file_path: python/functions/core/ask_llm_vision.py
---
# ask_llm_vision
Versión multimodal de [`ask_llm`](ask_llm.md): adjunta una imagen al prompt y devuelve la
respuesta del modelo. Usa la API directa de Anthropic con el token OAuth de Claude Max (sin
proceso `claude`, arranque 0), reutilizando [`stream_anthropic_messages`](stream_anthropic_messages.md),
que ya acepta content blocks multimodales. Cumple la regla `llm_invocation`: SIEMPRE claude-direct,
NUNCA `claude -p`.
## Ejemplo
```bash
# CLI (fn run): describe una imagen
fn run ask_llm_vision "describe esta imagen en una frase" --image ~/ComfyUI/output/demo_00001_.png
# Directo con el venv
python/.venv/bin/python3 python/functions/core/ask_llm_vision.py \
"que defectos ves en esta cara?" --image /tmp/render.png --model claude-opus-4-8
```
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from core.ask_llm_vision import ask_llm_vision
res = ask_llm_vision(
"Puntua de 0 a 10 el realismo de este retrato y justifica en una frase.",
image_path="/tmp/portrait.png",
model="claude-opus-4-8",
)
if res["ok"]:
print(res["text"])
else:
print("error:", res["error"])
```
## Cuando usarla
- Cuando necesites el juicio del modelo **sobre una imagen** (describir, puntuar, clasificar,
comparar, detectar defectos). Caso típico: cerrar el bucle de scoring de una skill ComfyUI —
generas un PNG y `ask_llm_vision` lo evalúa para alimentar `score_mean`.
- Si solo mandas texto, usa `ask_llm`. Si necesitas tools / loop agéntico, `run_claude_tool_loop_py_core`.
Si necesitas los eventos crudos (deltas, tool_use), `stream_anthropic_messages_py_core`.
## Gotchas
- **Es impura y hace red**: una request HTTP a `api.anthropic.com` por llamada. Sujeta a los
rate limits del plan (`HTTP 429` en ráfagas → espacia o reporta el error del dict).
- **media_type obligatorio con `image_b64`**: sin extensión de archivo no se puede deducir; pásalo
explícito (`image/png`, `image/jpeg`, `image/webp`, `image/gif`).
- **No lanza excepción**: errores (imagen inexistente, fallo HTTP) salen como `{ok: False, error: ...}`,
no como `raise`. Comprueba siempre `res["ok"]` antes de usar `res["text"]`.
- **Tamaño de la imagen**: imágenes muy grandes inflan los tokens de entrada (la API escala/limita
internamente). Para puntuar en lote conviene reducir la resolución antes.
- **Modelo con visión**: usa un modelo multimodal (`claude-opus-4-8` por defecto). Un id inexistente
da `404 not_found_error` propagado en `error`.
+167
View File
@@ -0,0 +1,167 @@
"""ask_llm_vision — pregunta multimodal (imagen + texto) al modelo via la API directa de Anthropic.
Wrapper sobre `stream_anthropic_messages` (grupo claude-direct) que construye un mensaje
multimodal `[bloque de imagen base64, bloque de texto]` y devuelve la respuesta del modelo.
Respeta la regla `llm_invocation`: SIEMPRE API directa (claude-direct, arranque 0 con el token
OAuth de Claude Max), NUNCA `claude -p`.
A diferencia de `ask_llm`, que solo manda texto, esta funcion adjunta una imagen — util para
describir, puntuar o clasificar imagenes (p.ej. evaluar el resultado de una generacion ComfyUI).
Impura: lee la imagen de disco y hace una request HTTP a api.anthropic.com.
"""
import base64
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from stream_anthropic_messages import stream_anthropic_messages # noqa: E402
DEFAULT_MODEL = "claude-opus-4-8"
_MEDIA_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".jpe": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
def _media_type_for(path: str) -> str:
"""Deduce el media_type MIME a partir de la extension del archivo (o "" si no se reconoce)."""
ext = os.path.splitext(path)[1].lower()
return _MEDIA_TYPES.get(ext, "")
def ask_llm_vision(
prompt: str,
image_path: str = "",
*,
image_b64: str = "",
media_type: str = "",
model: str = DEFAULT_MODEL,
system: str = "",
max_tokens: int = 4096,
echo: bool = False,
token: str = "",
) -> dict:
"""Pregunta al modelo sobre una imagen y devuelve su respuesta de texto.
Construye un mensaje multimodal con un bloque de imagen (base64) seguido del bloque de
texto del prompt, y lo envia a la API Messages de Anthropic via
`stream_anthropic_messages` (token OAuth de Claude Max, sin proceso `claude`).
Args:
prompt: pregunta/instruccion de texto sobre la imagen.
image_path: ruta a la imagen en disco. Se lee y codifica a base64; el media_type se
deduce de la extension si no se pasa. Ignorado si se pasa image_b64.
image_b64: alternativa a image_path: la imagen ya en base64 (sin prefijo `data:`).
Requiere `media_type` explicito. keyword-only.
media_type: tipo MIME de la imagen (image/png, image/jpeg, image/webp, image/gif).
Obligatorio con image_b64; deducido del path si se omite. keyword-only.
model: id del modelo Anthropic con vision. Default "claude-opus-4-8". keyword-only.
system: system prompt opcional. keyword-only.
max_tokens: maximo de tokens de salida. keyword-only.
echo: si True, vuelca el texto a stdout segun llega (streaming). keyword-only.
token: token OAuth; si vacio, lo carga `stream_anthropic_messages` automaticamente.
keyword-only.
Returns:
dict ``{ok, text, model, error}``. En exito ``ok=True`` y ``text`` lleva la respuesta
completa del modelo. En error ``ok=False``, ``text=""`` y ``error`` describe la causa
(imagen inexistente, falta media_type, error HTTP/API). Nunca lanza excepcion.
"""
if not image_path and not image_b64:
return {"ok": False, "text": "", "model": model,
"error": "falta la imagen: pasa image_path o image_b64"}
if image_b64:
b64 = image_b64
mt = media_type
if not mt:
return {"ok": False, "text": "", "model": model,
"error": "image_b64 requiere media_type explicito (ej. image/png)"}
else:
path = os.path.expanduser(image_path)
if not os.path.isfile(path):
return {"ok": False, "text": "", "model": model,
"error": f"imagen no encontrada: {path}"}
mt = media_type or _media_type_for(path)
if not mt:
return {"ok": False, "text": "", "model": model,
"error": f"no se pudo deducir media_type de {path!r}; pasa media_type explicito"}
try:
with open(path, "rb") as fh:
b64 = base64.standard_b64encode(fh.read()).decode("ascii")
except OSError as exc:
return {"ok": False, "text": "", "model": model,
"error": f"no se pudo leer la imagen: {exc}"}
content = [
{"type": "image", "source": {"type": "base64", "media_type": mt, "data": b64}},
{"type": "text", "text": prompt},
]
messages = [{"role": "user", "content": content}]
parts = []
for ev in stream_anthropic_messages(
messages=messages,
model=model,
system=system,
max_tokens=max_tokens,
token=token,
):
t = ev.get("type")
if t == "text":
parts.append(ev["text"])
if echo:
sys.stdout.write(ev["text"])
sys.stdout.flush()
elif t == "error":
return {"ok": False, "text": "", "model": model,
"error": ev.get("message", "error desconocido")}
if echo:
sys.stdout.write("\n")
sys.stdout.flush()
return {"ok": True, "text": "".join(parts), "model": model, "error": ""}
def _main(argv):
image_path = ""
model = DEFAULT_MODEL
system = ""
prompt_parts = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("--image", "-i") and i + 1 < len(argv):
image_path = argv[i + 1]
i += 2
elif a in ("--model", "-m") and i + 1 < len(argv):
model = argv[i + 1]
i += 2
elif a in ("--system", "-s") and i + 1 < len(argv):
system = argv[i + 1]
i += 2
else:
prompt_parts.append(a)
i += 1
prompt = " ".join(prompt_parts).strip()
if not image_path:
sys.stderr.write('uso: ask_llm_vision "prompt" --image RUTA [--model M] [--system S]\n')
return 2
if not prompt:
prompt = "Describe esta imagen."
res = ask_llm_vision(prompt, image_path, model=model, system=system, echo=True)
if not res["ok"]:
sys.stderr.write("ask_llm_vision error: " + str(res["error"]) + "\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
@@ -0,0 +1,89 @@
---
name: comfyui_build_skill_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict"
description: "Compila una receta de skill ComfyUI (dict recipe.json del grupo comfyui-skill) a un workflow en API format listo para comfyui_submit_workflow. Despacha al builder base segun recipe['base_workflow'] (txt2img|flux|sdxl_refiner), sustituye {subject} y los trigger_words en el prompt_scaffold, encadena los loras (inject_lora) y aplica los blocks de post-proceso (facedetailer, hires_fix) en orden. Pura: solo compone builders puros del registry, sin red ni I/O. base_workflow desconocido o que requiere imagen -> SkillWorkflowError."
tags: [comfyui, comfyui-skill, ml, workflow, stable-diffusion, skill]
uses_functions:
- comfyui_build_txt2img_workflow_py_ml
- comfyui_build_flux_workflow_py_ml
- comfyui_build_sdxl_refiner_workflow_py_ml
- comfyui_inject_lora_py_ml
- comfyui_build_facedetailer_workflow_py_ml
- comfyui_inject_hires_fix_py_ml
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: recipe
desc: "Dict de la receta (schema comfyui-skill). Campos usados: base_workflow (txt2img|flux|sdxl_refiner), checkpoint, loras [{name, strength_model, strength_clip}], params (steps/cfg/width/height/sampler_name/scheduler/...), prompt_scaffold (positive/negative con {subject} + trigger_words), blocks [{type, params}]."
- name: subject
desc: "El sujeto concreto que sustituye {subject} en el scaffold del prompt (ej. 'a woman with red hair')."
- name: seed
desc: "Semilla de generacion; se pasa al builder base y por defecto a cada bloque que la acepte. keyword-only."
output: "dict en API format (nodos numerados con class_type + inputs) listo para comfyui_submit_workflow."
tested: true
tests: ["golden: txt2img + 1 lora + facedetailer -> API format valido con LoraLoader + FaceDetailer y subject sustituido", "edge: sin loras ni blocks -> workflow base minimo (6 class_types)", "params seed/steps/cfg/width + trigger_words reflejados", "error: base_workflow desconocido -> SkillWorkflowError", "error: base que requiere imagen (img2img) -> SkillWorkflowError", "error: recipe no dict -> SkillWorkflowError"]
test_file_path: "python/functions/ml/tests/test_comfyui_build_skill_workflow.py"
file_path: "python/functions/ml/comfyui_build_skill_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_skill_workflow import build_skill_workflow
recipe = {
"schema_version": 1,
"slug": "portrait_cinematic_sdxl",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
}
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
# wf tiene CheckpointLoaderSimple + KSampler + LoraLoader + FaceDetailer + SaveImage.
# Pasalo a comfyui_submit_workflow para encolarlo.
```
O lanzable directo con `./fn run comfyui_build_skill_workflow` (imprime el JSON del workflow de ejemplo).
## Cuando usarla
- Cuando tengas una **receta de skill** (de `comfyui_load_skill`) y quieras convertirla en un
workflow concreto para un `subject` dado, sin montar el grafo a mano. Es el paso "receta →
workflow" del flujo del grupo `comfyui-skill`: `load_skill``build_skill_workflow`
`submit_workflow``wait_result`.
- Cuando reutilices una configuración probada (checkpoint + LoRAs + params + post-proceso)
cambiando solo el sujeto y la semilla.
## Gotchas
- **Pura, NO valida contra el servidor**: igual que los builders hermanos, no comprueba que el
checkpoint/LoRA/modelo de upscale existan en ComfyUI. Si faltan, el servidor rechaza el
workflow con HTTP 400 al enviarlo. Valida antes con `comfyui_validate_workflow` si hace falta.
- **Solo bases de texto**: soporta `base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`}. Las
bases que requieren una imagen de entrada (`img2img`, `inpaint`, `controlnet`) lanzan
`SkillWorkflowError``build_skill_workflow` arranca de un `subject` de texto, no de una imagen.
- **`sdxl_refiner` requiere `params['refiner_ckpt']`**; sin él lanza `SkillWorkflowError`.
- **Flux ignora el negativo**: el builder de Flux usa un negativo vacío fijo y la guía va por
`FluxGuidance`; en Flux el campo `checkpoint` de la receta se mapea a `unet`.
- **El orden de los `blocks` importa**: se aplican secuencialmente sobre el dict. `facedetailer`
toma la imagen del `VAEDecode`; `hires_fix` re-difunde y repunta el `SaveImage`. Encadénalos en
el orden lógico (p.ej. hires_fix tras facedetailer).
- **Excepción tipada**: todos los errores de compilación son `SkillWorkflowError` (subclase de
`ValueError`), exportada por el módulo.
@@ -0,0 +1,213 @@
"""comfyui_build_skill_workflow — compila una receta de *skill* a un workflow ComfyUI.
Una **skill** es una receta versionada (el dict de `recipe.json` del grupo `comfyui-skill`) que
fija checkpoint, LoRAs, parametros de sampling, scaffold de prompt y bloques de post-proceso.
Esta funcion PURA la compila a un dict de workflow en "API format" listo para
`comfyui_submit_workflow`, componiendo los builders del registry segun `recipe['base_workflow']`.
Despacho de `base_workflow` (los que se construyen solo a partir de un `subject` de texto):
txt2img -> comfyui_build_txt2img_workflow
flux -> comfyui_build_flux_workflow
sdxl_refiner -> comfyui_build_sdxl_refiner_workflow
Bloques de post-proceso (`recipe['blocks']`, aplicados en orden sobre el dict resultante):
facedetailer -> comfyui_build_facedetailer_workflow (encadena sobre el dict)
hires_fix -> comfyui_inject_hires_fix (encadena sobre el dict)
Pasos: scaffold de prompt ({subject} + trigger_words) -> builder base -> LoRAs (inject_lora) ->
bloques (en orden). Los `base_workflow` que necesitan una imagen de entrada (img2img, inpaint,
controlnet) NO se soportan aqui — `build_skill_workflow` arranca de texto.
Funcion pura: sin red, sin I/O. Solo compone builders puros del registry.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
class SkillWorkflowError(ValueError):
"""Error tipado al compilar una receta de skill (base_workflow desconocido,
recipe invalida, bloque no soportado, dependencia ausente)."""
_IMAGE_INPUT_BASES = {"img2img", "inpaint", "controlnet"}
def _pick(params: dict, keys) -> dict:
"""Subconjunto de `params` con solo las claves presentes en `keys`."""
return {k: params[k] for k in keys if k in params}
def _scaffold_prompts(recipe: dict, subject: str) -> tuple[str, str]:
"""Sustituye `{subject}` y antepone los trigger_words en el scaffold de prompt.
Devuelve (positive, negative). Si no hay `prompt_scaffold`, usa el subject como
positivo y "" como negativo.
"""
scaffold = recipe.get("prompt_scaffold") or {}
positive = str(scaffold.get("positive", "") or "")
negative = str(scaffold.get("negative", "") or "")
if "{subject}" in positive:
positive = positive.replace("{subject}", subject)
elif not positive:
positive = subject
else:
# scaffold sin placeholder: el subject se antepone para no perderlo.
positive = f"{subject}, {positive}"
negative = negative.replace("{subject}", subject)
triggers = scaffold.get("trigger_words") or []
if triggers:
positive = ", ".join(list(triggers) + [positive]) if positive else ", ".join(triggers)
return positive, negative
def _build_base(recipe: dict, positive: str, negative: str, seed: int) -> dict:
"""Despacha al builder base del registry segun `recipe['base_workflow']`."""
base = recipe.get("base_workflow")
params = dict(recipe.get("params") or {})
ckpt = recipe.get("checkpoint", "")
if base == "txt2img":
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
if not ckpt:
raise SkillWorkflowError("base_workflow txt2img requiere recipe['checkpoint']")
kw = _pick(params, ("steps", "cfg", "width", "height", "sampler_name", "scheduler"))
return comfyui_build_txt2img_workflow(ckpt, positive, negative, seed=seed, **kw)
if base == "flux":
from ml.comfyui_build_flux_workflow import comfyui_build_flux_workflow
kw = _pick(params, ("clip_l", "t5xxl", "vae", "width", "height", "steps",
"guidance", "weight_dtype", "sampler_name", "scheduler"))
# En Flux el "checkpoint" de la receta es el modelo de difusion (unet).
if ckpt:
kw.setdefault("unet", ckpt)
return comfyui_build_flux_workflow(positive, seed=seed, **kw)
if base == "sdxl_refiner":
from ml.comfyui_build_sdxl_refiner_workflow import comfyui_build_sdxl_refiner_workflow
refiner = params.get("refiner_ckpt")
if not ckpt or not refiner:
raise SkillWorkflowError(
"base_workflow sdxl_refiner requiere recipe['checkpoint'] (base) y "
"recipe['params']['refiner_ckpt']")
kw = _pick(params, ("base_steps", "refiner_steps", "cfg", "width", "height"))
return comfyui_build_sdxl_refiner_workflow(ckpt, refiner, positive, negative, seed=seed, **kw)
if base in _IMAGE_INPUT_BASES:
raise SkillWorkflowError(
f"base_workflow {base!r} requiere una imagen de entrada; build_skill_workflow "
"compila a partir de un subject de texto. Construye ese workflow aparte.")
raise SkillWorkflowError(
f"base_workflow desconocido: {base!r}. Soportados: txt2img, flux, sdxl_refiner.")
def _apply_loras(workflow: dict, loras) -> dict:
"""Encadena los LoRAs de la receta via comfyui_inject_lora (en orden)."""
if not loras:
return workflow
from ml.comfyui_inject_lora import comfyui_inject_lora
wf = workflow
for lora in loras:
name = lora.get("name")
if not name:
raise SkillWorkflowError("cada lora de la receta necesita 'name'")
wf = comfyui_inject_lora(
wf,
name,
strength_model=lora.get("strength_model", 1.0),
strength_clip=lora.get("strength_clip", 1.0),
)
return wf
def _apply_block(workflow: dict, block: dict, recipe: dict, positive: str,
negative: str, seed: int) -> dict:
"""Aplica un bloque de post-proceso sobre el workflow (facedetailer | hires_fix)."""
btype = block.get("type")
bparams = dict(block.get("params") or {})
if btype == "facedetailer":
from ml.comfyui_build_facedetailer_workflow import comfyui_build_facedetailer_workflow
bparams.setdefault("seed", seed)
return comfyui_build_facedetailer_workflow(
workflow, recipe.get("checkpoint", ""), positive, negative, **bparams)
if btype == "hires_fix":
try:
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix
except ImportError as exc:
raise SkillWorkflowError(
"bloque hires_fix requiere comfyui_inject_hires_fix_py_ml (no disponible)") from exc
bparams.setdefault("seed", seed)
return comfyui_inject_hires_fix(workflow, **bparams)
raise SkillWorkflowError(
f"bloque de tipo desconocido: {btype!r}. Soportados: facedetailer, hires_fix.")
def build_skill_workflow(recipe: dict, subject: str, *, seed: int = 0) -> dict:
"""Compila una receta de skill a un workflow ComfyUI en API format.
Args:
recipe: dict de la receta (schema `comfyui-skill`). Campos usados:
`base_workflow` (txt2img|flux|sdxl_refiner), `checkpoint`, `loras`,
`params`, `prompt_scaffold` (`positive`/`negative` con `{subject}` +
`trigger_words`), `blocks` (lista de `{type, params}`).
subject: el sujeto concreto que sustituye `{subject}` en el scaffold del
prompt (p.ej. "a woman with red hair").
seed: semilla de generacion; se pasa al builder base y por defecto a cada
bloque que la acepte. keyword-only.
Returns:
dict en API format listo para `comfyui_submit_workflow`.
Raises:
SkillWorkflowError: si `base_workflow` es desconocido o necesita imagen, si
la receta no es un dict valido, si falta un checkpoint requerido, o si un
bloque es de tipo no soportado / su dependencia no esta disponible.
"""
if not isinstance(recipe, dict):
raise SkillWorkflowError(f"recipe debe ser dict, no {type(recipe).__name__}")
positive, negative = _scaffold_prompts(recipe, subject)
workflow = _build_base(recipe, positive, negative, seed)
workflow = _apply_loras(workflow, recipe.get("loras"))
for block in (recipe.get("blocks") or []):
if not isinstance(block, dict):
raise SkillWorkflowError(f"cada block debe ser dict, no {type(block).__name__}")
workflow = _apply_block(workflow, block, recipe, positive, negative, seed)
return workflow
# Alias con el nombre completo del ID para descubrimiento por convencion.
comfyui_build_skill_workflow = build_skill_workflow
if __name__ == "__main__":
import json
demo = {
"schema_version": 1,
"slug": "portrait_demo",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}],
}
wf = build_skill_workflow(demo, "a woman with red hair", seed=42)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,83 @@
---
name: comfyui_inject_hires_fix
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_inject_hires_fix(workflow: dict, *, upscale_by: float = 1.5, denoise: float = 0.4, steps: int = 20, cfg: float = 7.0, seed: int = 0, upscale_model: str = '4x_foolhardy_Remacri.pth', sampler_name: str = 'euler', scheduler: str = 'normal', tile_width: int = 512, tile_height: int = 512) -> dict"
description: "Inyecta una segunda pasada hires-fix en un workflow ComfyUI ya construido (API format) que termina en VAEDecode -> SaveImage. Anade UpscaleModelLoader + UltimateSDUpscale (re-difusion por tiles) conectados a la imagen del VAEDecode y al model/vae del CheckpointLoaderSimple, y repunta el SaveImage a la imagen ampliada. Version encadenable-sobre-dict de comfyui_build_hires_fix_workflow. Pura: no muta el dict de entrada (copia profunda)."
tags: [comfyui, comfyui-skill, ml, hires-fix, upscale, workflow, stable-diffusion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: workflow
desc: "dict en API format (ej. salida de comfyui_build_txt2img_workflow) que termina en VAEDecode -> SaveImage. No se muta; se devuelve una copia."
- name: upscale_by
desc: "Factor de ampliacion de UltimateSDUpscale sobre la imagen base (1.5 -> 512 pasa a 768). keyword-only."
- name: denoise
desc: "Fuerza de re-difusion de la segunda pasada (0.4 por defecto). <1 conserva la composicion base y solo anade detalle; 1.0 la re-generaria entera. keyword-only."
- name: steps
desc: "Pasos de sampling de la re-difusion tiled. keyword-only."
- name: cfg
desc: "Classifier-free guidance de la re-difusion. keyword-only."
- name: seed
desc: "Semilla de UltimateSDUpscale. keyword-only."
- name: upscale_model
desc: "Modelo de upscale en models/upscale_models/ que usa UltimateSDUpscale para escalar antes de re-difundir (ej. '4x_foolhardy_Remacri.pth'). keyword-only."
- name: sampler_name
desc: "Sampler de la re-difusion. keyword-only."
- name: scheduler
desc: "Scheduler de la re-difusion. keyword-only."
- name: tile_width
desc: "Ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos = menos VRAM, mas costuras. keyword-only."
- name: tile_height
desc: "Alto de tile de UltimateSDUpscale (px). keyword-only."
output: "copia del workflow con UpscaleModelLoader + UltimateSDUpscale anadidos (node_ids = max id numerico + 1 y + 2) y el SaveImage repuntado a la salida [ultimatesdupscale_id, 0]. Si no habia SaveImage, se anade uno con filename_prefix 'hires'."
tested: true
tests: ["no muta el dict de entrada (pureza)", "inserta UltimateSDUpscale y UpscaleModelLoader", "repunta el SaveImage al UltimateSDUpscale", "params reflejados (upscale_by/denoise/seed)", "lanza ValueError si falta VAEDecode"]
test_file_path: "python/functions/ml/tests/test_comfyui_inject_hires_fix.py"
file_path: "python/functions/ml/comfyui_inject_hires_fix.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a fox in a forest, detailed")
# El base termina en VAEDecode ["8",0] -> SaveImage ["9"].
wf = comfyui_inject_hires_fix(base, upscale_by=2.0, denoise=0.35, seed=42)
# Ahora: VAEDecode -> UltimateSDUpscale -> SaveImage, con UpscaleModelLoader cargando Remacri.
# El SaveImage["9"].inputs["images"] apunta al nuevo UltimateSDUpscale, no al VAEDecode.
```
## Cuando usarla
Cuando ya tengas un workflow txt2img/img2img construido (o devuelto por otro
builder) y quieras anadirle el hires fix sin reescribir el grafo desde cero.
A diferencia de `comfyui_build_hires_fix_workflow`, que construye el grafo entero
de una vez, esta lo ENCADENA sobre un dict existente: util tras inyectar LoRAs
con `comfyui_inject_lora` o partiendo de cualquier base que termine en
VAEDecode -> SaveImage. Una sola llamada anade la segunda pasada completa.
## Gotchas
- Pura: no muta el `workflow` de entrada (trabaja sobre una copia profunda) y NO
valida que `upscale_model` exista en el servidor. Valida con `comfyui_validate_workflow`.
- Requiere el custom node UltimateSDUpscale instalado en el servidor ComfyUI; el
dict se construye igual aunque no este, pero el submit fallara.
- Detecta el VAEDecode (fuente de imagen), el CheckpointLoaderSimple (model slot 0,
vae slot 2) y los CLIPTextEncode positive/negative por el KSampler existente. Si
no hay VAEDecode o CheckpointLoaderSimple, lanza ValueError.
- Si el workflow tiene varios VAEDecode/SaveImage, se usa el PRIMERO encontrado.
Para grafos multi-salida construye con un builder dedicado.
- El nuevo node_id es `max(ids numericos) + 1` (y +2). Si tu workflow usa ids no
numericos, el contador cae a `len(workflow) + 1`.
@@ -0,0 +1,179 @@
"""Inyecta una segunda pasada "hires fix" en un workflow ComfyUI ya construido.
Toma un workflow en API format (dict, p.ej. salida de
comfyui_build_txt2img_workflow) que termina en VAEDecode -> SaveImage y le
encadena una re-difusion por tiles con UltimateSDUpscale + un modelo de upscale
(ESRGAN/Remacri), repuntando el SaveImage para que guarde la imagen ampliada en
vez de la base:
... -> VAEDecode ----------------+--> SaveImage (antes)
|
... -> VAEDecode -> UltimateSDUpscale -> SaveImage (despues)
UpscaleModelLoader ----^
Es la version ENCADENABLE-sobre-dict del builder comfyui_build_hires_fix_workflow,
que construye el grafo entero desde cero y NO encadena. Reusa exactamente los
mismos class_types e inputs (mode_type 'Linear', mask_blur 8, tile_padding 32,
seam_fix_mode 'None', force_uniform_tiles True, tiled_decode False, etc.).
UltimateSDUpscale ES la segunda pasada de muestreo: re-samplea cada tile con el
checkpoint (de ahi que reciba `model`, `positive`, `negative`, `vae`).
Funcion pura: sin red, sin I/O. No muta el dict de entrada (copia profunda).
"""
import copy
def comfyui_inject_hires_fix(
workflow: dict,
*,
upscale_by: float = 1.5,
denoise: float = 0.4,
steps: int = 20,
cfg: float = 7.0,
seed: int = 0,
upscale_model: str = "4x_foolhardy_Remacri.pth",
sampler_name: str = "euler",
scheduler: str = "normal",
tile_width: int = 512,
tile_height: int = 512,
) -> dict:
"""Devuelve una copia del workflow con la segunda pasada hires-fix inyectada.
Args:
workflow: dict en API format (ej. salida de
comfyui_build_txt2img_workflow) que termina en VAEDecode -> SaveImage.
No se muta; se devuelve una copia.
upscale_by: factor de ampliacion de UltimateSDUpscale sobre la imagen base
(1.5 -> 512 pasa a 768). keyword-only.
denoise: fuerza de re-difusion de la segunda pasada (0.4 por defecto). <1
conserva la composicion base y solo anade detalle; 1.0 la re-generaria
entera. keyword-only.
steps: pasos de sampling de la re-difusion tiled. keyword-only.
cfg: classifier-free guidance de la re-difusion. keyword-only.
seed: semilla de UltimateSDUpscale. keyword-only.
upscale_model: modelo de upscale en models/upscale_models/ que usa
UltimateSDUpscale para escalar antes de re-difundir (ej.
"4x_foolhardy_Remacri.pth"). keyword-only.
sampler_name: sampler de la re-difusion. keyword-only.
scheduler: scheduler de la re-difusion. keyword-only.
tile_width: ancho de tile de UltimateSDUpscale (px). Tiles mas pequenos =
menos VRAM, mas costuras. keyword-only.
tile_height: alto de tile de UltimateSDUpscale (px). keyword-only.
Returns:
copia del workflow con UpscaleModelLoader + UltimateSDUpscale anadidos
(node_ids = max id numerico existente + 1 y + 2) y el SaveImage repuntado
a la salida de UltimateSDUpscale. Si no habia SaveImage, se anade uno.
Raises:
ValueError: si el workflow no contiene un VAEDecode (fuente de imagen) o
un CheckpointLoaderSimple (model/vae para la re-difusion).
"""
wf = copy.deepcopy(workflow)
def _find_class(prefix):
for nid, node in wf.items():
if str(node.get("class_type", "")).startswith(prefix):
return nid
return None
vaedecode = _find_class("VAEDecode")
if vaedecode is None:
raise ValueError(
"comfyui_inject_hires_fix: no se encontro ningun nodo VAEDecode "
"(fuente de imagen) en el workflow."
)
ckpt = _find_class("CheckpointLoaderSimple")
if ckpt is None:
raise ValueError(
"comfyui_inject_hires_fix: no se encontro ningun nodo "
"CheckpointLoaderSimple (model/vae para la re-difusion) en el workflow."
)
def _is_link(v) -> bool:
return (
isinstance(v, list)
and len(v) == 2
and isinstance(v[0], str)
and isinstance(v[1], int)
)
# positive/negative: los mismos CLIPTextEncode que alimentan el KSampler.
pos_src = [ckpt, 0]
neg_src = [ckpt, 0]
for node in wf.values():
if str(node.get("class_type", "")).endswith("KSampler"):
ins = node.get("inputs", {})
if _is_link(ins.get("positive")):
pos_src = list(ins["positive"])
if _is_link(ins.get("negative")):
neg_src = list(ins["negative"])
break
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
base = (max(numeric) + 1) if numeric else len(wf) + 1
loader_id = str(base)
upscale_id = str(base + 1)
wf[loader_id] = {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": upscale_model},
}
wf[upscale_id] = {
"class_type": "UltimateSDUpscale",
"inputs": {
"image": [vaedecode, 0],
"model": [ckpt, 0],
"positive": list(pos_src),
"negative": list(neg_src),
"vae": [ckpt, 2],
"upscale_model": [loader_id, 0],
"upscale_by": upscale_by,
"seed": seed,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": denoise,
"mode_type": "Linear",
"tile_width": tile_width,
"tile_height": tile_height,
"mask_blur": 8,
"tile_padding": 32,
"seam_fix_mode": "None",
"seam_fix_denoise": 1.0,
"seam_fix_width": 64,
"seam_fix_mask_blur": 8,
"seam_fix_padding": 16,
"force_uniform_tiles": True,
"tiled_decode": False,
},
}
# repuntar el SaveImage existente al UltimateSDUpscale; si no hay, anadir uno.
save_id = _find_class("SaveImage")
if save_id is not None:
wf[save_id]["inputs"]["images"] = [upscale_id, 0]
else:
new_save_id = str(base + 2)
wf[new_save_id] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "hires", "images": [upscale_id, 0]},
}
return wf
if __name__ == "__main__":
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
base = comfyui_build_txt2img_workflow("dreamshaper_8.safetensors", "a cat, detailed")
wf = comfyui_inject_hires_fix(base, upscale_by=2.0, denoise=0.35, seed=42)
print(json.dumps(wf, indent=2))
@@ -0,0 +1,58 @@
---
name: comfyui_list_skills
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
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]
uses_functions: []
uses_types: []
params:
- name: library_dir
desc: "Raiz de la libreria en disco. Default ~/ComfyUI/skills_library. keyword-only."
- name: include_nsfw
desc: "Si False (default), oculta las skills con provenance.nsfw == True. keyword-only."
output: "dict {ok, skills, count, error}. skills es lista de {slug, title, base_workflow, version, score_mean, score_n, nsfw, n_versions} ordenada por slug."
file_path: python/functions/ml/comfyui_list_skills.py
---
# comfyui_list_skills
Inventario de la libreria de skills del grupo `comfyui-skill`. Complementa
[`comfyui_save_skill`](comfyui_save_skill.md) y [`comfyui_load_skill`](comfyui_load_skill.md).
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_list_skills import comfyui_list_skills
res = comfyui_list_skills(library_dir="/tmp/skills_demo")
for s in res["skills"]:
print(s["slug"], s["base_workflow"], s["score_mean"], s["n_versions"])
# Incluir las marcadas NSFW
todas = comfyui_list_skills(library_dir="/tmp/skills_demo", include_nsfw=True)
```
O directo: `./fn run comfyui_list_skills --library-dir /tmp/skills_demo`.
## Cuando usarla
- Para descubrir qué skills hay disponibles antes de cargar una con `comfyui_load_skill`.
- Para construir un selector/menú de skills (ordenado por `score_mean` para sugerir las mejores).
## Gotchas
- **`include_nsfw=False` por defecto**: las skills con `provenance.nsfw == True` se ocultan salvo
que lo pidas explícito. El filtro es por la metadata de la receta, no por inspección del contenido.
- **Libreria inexistente = lista vacía**: si `library_dir` no existe, devuelve
`{ok: True, skills: [], count: 0}` (no error). Carpetas sin `recipe.json` se ignoran.
- **`score_mean`/`score_n` los rellena el bucle de scoring** (otra pieza del grupo); aquí solo se
leen tal cual estén en la receta (0 si no se han puntuado).
- **No lanza excepción**: un error de E/S al listar la raíz devuelve `{ok: False, error: ...}`.
@@ -0,0 +1,86 @@
"""comfyui_list_skills — lista las *skills* ComfyUI guardadas en la libreria de disco.
Escanea `<library_dir>/<slug>/recipe.json` y devuelve un resumen por skill: slug, title,
base_workflow, version, score_mean/score_n (del bucle de scoring) y nsfw (de
`provenance.nsfw`), mas el numero de snapshots en `versions/`.
Respeta `include_nsfw=False` (por defecto): oculta las skills marcadas NSFW. Una libreria
inexistente o vacia devuelve una lista vacia sin error.
Impura: lee el arbol de la libreria en disco.
"""
import json
import os
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _n_versions(skill_dir):
versions_dir = os.path.join(skill_dir, "versions")
if not os.path.isdir(versions_dir):
return 0
return len([f for f in os.listdir(versions_dir)
if f.startswith("v") and f.endswith(".json")])
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.
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
``{slug, title, base_workflow, version, score_mean, score_n, nsfw, n_versions}``
ordenada por slug. Libreria inexistente -> lista vacia, ``ok=True``. Nunca lanza.
"""
lib = _lib_dir(library_dir)
if not os.path.isdir(lib):
return {"ok": True, "skills": [], "count": 0, "error": ""}
skills = []
try:
entries = sorted(os.listdir(lib))
except OSError as exc:
return {"ok": False, "skills": [], "count": 0, "error": f"no se pudo listar la libreria: {exc}"}
for slug in entries:
skill_dir = os.path.join(lib, slug)
recipe_path = os.path.join(skill_dir, "recipe.json")
if not os.path.isfile(recipe_path):
continue
try:
with open(recipe_path, encoding="utf-8") as fh:
r = json.load(fh)
except (OSError, json.JSONDecodeError):
continue
prov = r.get("provenance") or {}
nsfw = bool(prov.get("nsfw"))
if nsfw and not include_nsfw:
continue
skills.append({
"slug": r.get("slug", slug),
"title": r.get("title", ""),
"base_workflow": r.get("base_workflow", ""),
"version": r.get("version", ""),
"score_mean": r.get("score_mean", 0),
"score_n": r.get("score_n", 0),
"nsfw": nsfw,
"n_versions": _n_versions(skill_dir),
})
return {"ok": True, "skills": skills, "count": len(skills), "error": ""}
if __name__ == "__main__":
res = comfyui_list_skills(library_dir="/tmp/skills_demo")
print(json.dumps(res, indent=2, ensure_ascii=False))
+63
View File
@@ -0,0 +1,63 @@
---
name: comfyui_load_skill
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
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]
uses_functions: []
uses_types: []
params:
- name: slug
desc: "Slug de la skill (nombre de su carpeta en la libreria)."
- name: version
desc: "Si None, lee recipe.json (version actual). Si se pasa (int 1, '1' o 'v1'), lee el snapshot versions/vN.json. keyword-only."
- name: library_dir
desc: "Raiz de la libreria en disco. Default ~/ComfyUI/skills_library. keyword-only."
output: "dict {ok, recipe, slug, path, version, error}. En exito ok=True y recipe es el dict guardado; si slug/version/archivo no existen ok=False y recipe=None."
file_path: python/functions/ml/comfyui_load_skill.py
---
# comfyui_load_skill
Carga una receta de skill guardada por [`comfyui_save_skill`](comfyui_save_skill.md). Paso de
entrada del flujo del grupo `comfyui-skill`: `load_skill`
[`comfyui_build_skill_workflow`](comfyui_build_skill_workflow.md) → submit → wait.
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_load_skill import comfyui_load_skill
# Version actual
res = comfyui_load_skill("portrait_cinematic_sdxl", library_dir="/tmp/skills_demo")
if res["ok"]:
recipe = res["recipe"]
# Un snapshot concreto (v1, v2, ...)
old = comfyui_load_skill("portrait_cinematic_sdxl", version=1, library_dir="/tmp/skills_demo")
```
O directo: `./fn run comfyui_load_skill demo_skill --library-dir /tmp/skills_demo`.
## Cuando usarla
- Para recuperar una receta antes de compilarla con `comfyui_build_skill_workflow`.
- Para inspeccionar un snapshot histórico concreto (`version=N`) y comparar cómo evolucionó una
skill.
## Gotchas
- **No lanza excepción**: slug inexistente, versión inválida o archivo ausente devuelven
`{ok: False, recipe: None, error: ...}`. Comprueba `res["ok"]` antes de usar `res["recipe"]`.
- **`version` acepta varios formatos**: int `1`, str `"1"` o `"v1"` apuntan a `versions/v1.json`.
Cualquier otra cosa da error de versión inválida.
- **Round-trip exacto con save**: lee el JSON tal cual se guardó; no normaliza ni rellena campos.
- **library_dir por defecto `~/ComfyUI/skills_library`**: pásalo explícito para librerías de
test o aisladas.
+89
View File
@@ -0,0 +1,89 @@
"""comfyui_load_skill — lee una receta de *skill* ComfyUI de la libreria de disco.
Carga `recipe.json` (version actual) o un snapshot concreto `versions/vN.json` de una skill
guardada por `comfyui_save_skill`. Hermana inversa de save: el round-trip
save(recipe) -> load(slug) devuelve un dict identico al guardado.
`library_dir` por defecto `~/ComfyUI/skills_library`. Un slug inexistente devuelve
``{ok: False}`` sin lanzar excepcion.
Impura: lee archivos de disco.
"""
import json
import os
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _version_filename(version):
"""Normaliza la version a un nombre de archivo `vN.json`.
Acepta int (1), str de digitos ("1") o ya prefijada ("v1"). Devuelve None si no
se puede interpretar.
"""
if isinstance(version, int):
return f"v{version}.json"
s = str(version).strip()
if s.startswith("v"):
s = s[1:]
if s.isdigit():
return f"v{s}.json"
return None
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.
Returns:
dict ``{ok, recipe, slug, path, version, error}``. En exito ``ok=True`` y `recipe`
es el dict guardado. Si el slug, la version o el archivo no existen, ``ok=False``,
``recipe=None`` y ``error`` describe la causa; nunca lanza.
"""
if not slug or not isinstance(slug, str):
return {"ok": False, "recipe": None, "slug": slug, "path": "", "version": version,
"error": "slug requerido (string no vacio)"}
lib = _lib_dir(library_dir)
skill_dir = os.path.join(lib, slug)
if not os.path.isdir(skill_dir):
return {"ok": False, "recipe": None, "slug": slug, "path": skill_dir, "version": version,
"error": f"skill no encontrada: {slug!r}"}
if version is None:
target = os.path.join(skill_dir, "recipe.json")
else:
fname = _version_filename(version)
if fname is None:
return {"ok": False, "recipe": None, "slug": slug, "path": skill_dir,
"version": version, "error": f"version invalida: {version!r}"}
target = os.path.join(skill_dir, "versions", fname)
if not os.path.isfile(target):
return {"ok": False, "recipe": None, "slug": slug, "path": target, "version": version,
"error": f"archivo de receta no encontrado: {target}"}
try:
with open(target, encoding="utf-8") as fh:
recipe = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "recipe": None, "slug": slug, "path": target, "version": version,
"error": f"no se pudo leer la receta: {exc}"}
return {"ok": True, "recipe": recipe, "slug": slug, "path": target, "version": version,
"error": ""}
if __name__ == "__main__":
res = comfyui_load_skill("demo_skill", library_dir="/tmp/skills_demo")
print(json.dumps(res, indent=2, ensure_ascii=False))
+72
View File
@@ -0,0 +1,72 @@
---
name: comfyui_save_skill
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
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]
uses_functions: []
uses_types: []
params:
- name: recipe
desc: "Dict de la receta (schema comfyui-skill). Requiere al menos slug, base_workflow y version (strings no vacios). No se muta."
- name: library_dir
desc: "Raiz de la libreria en disco. Default ~/ComfyUI/skills_library. keyword-only."
output: "dict {ok, slug, path, recipe_path, version_file, n_versions, error}. En error de validacion o escritura ok=False y error describe la causa."
file_path: python/functions/ml/comfyui_save_skill.py
---
# comfyui_save_skill
Escribe una receta de skill en la libreria de disco con versionado por snapshots. Parte del CRUD
del grupo `comfyui-skill` ([`comfyui_load_skill`](comfyui_load_skill.md),
[`comfyui_list_skills`](comfyui_list_skills.md)).
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_save_skill import comfyui_save_skill
recipe = {
"schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0",
"title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m", "scheduler": "karras"},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}", "negative": "blurry",
"trigger_words": []},
"provenance": {"source": "manual", "nsfw": False},
}
res = comfyui_save_skill(recipe, library_dir="/tmp/skills_demo")
# {'ok': True, 'slug': 'portrait_cinematic_sdxl', 'n_versions': 1,
# 'version_file': '/tmp/skills_demo/portrait_cinematic_sdxl/versions/v1.json', ...}
```
O directo: `./fn run comfyui_save_skill` (guarda una skill demo en /tmp/skills_demo).
## Cuando usarla
- Cuando crees o actualices una receta de skill y quieras persistirla con versionado. Cada save
escribe un snapshot nuevo `versions/vN.json` además de actualizar `recipe.json`, así que el
historial queda intacto.
- Tras ajustar params/loras/blocks de una skill que iterabas: guárdala para reproducirla luego
con `comfyui_load_skill` + `comfyui_build_skill_workflow`.
## Gotchas
- **No muta la receta**: lo escrito en `recipe.json` es idéntico al dict de entrada (garantiza el
round-trip con `comfyui_load_skill`). No rellena defaults ni reordena.
- **Validación mínima**: exige `slug`, `base_workflow` y `version` (strings no vacíos); el `slug`
no puede contener separadores de ruta ni empezar por `.`. No valida la semántica completa del
workflow (eso lo hace `comfyui_build_skill_workflow` al compilar).
- **`n_versions` cuenta los snapshots existentes + 1**: guardar la misma skill dos veces crea
`v1.json` y `v2.json`; `recipe.json` siempre apunta a la última.
- **No lanza excepción**: errores (validación, permisos de escritura) salen como `{ok: False, error: ...}`.
- **Escribe bajo `~/ComfyUI/skills_library` por defecto** (carpeta de metadata, no toca el venv
ni los modelos). Para tests/uso aislado pasa un `library_dir` propio.
+160
View File
@@ -0,0 +1,160 @@
"""comfyui_save_skill — persiste una receta de *skill* ComfyUI en la libreria de disco.
Una **skill** es una receta versionada del grupo `comfyui-skill`. Esta funcion valida el schema
minimo de la receta y la escribe en la libreria:
<library_dir>/<slug>/
recipe.json # version actual (la receta tal cual, sin mutar)
versions/vN.json # snapshot inmutable de cada save (N incremental)
exports/ # plantillas de workflow exportadas (vacio al crear)
samples/ # imagenes de muestra (vacio al crear)
growth_log.jsonl # bitacora append-only de cada save
<library_dir>/INDEX.md # indice regenerado con todas las skills
`library_dir` por defecto `~/ComfyUI/skills_library`. La funcion NO muta la receta: lo que se
escribe en `recipe.json` es identico al dict de entrada (garantiza round-trip con
`comfyui_load_skill`).
Impura: escribe archivos en disco.
"""
import json
import os
import time
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
_REQUIRED = ("slug", "base_workflow", "version")
def _lib_dir(library_dir):
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
def _validate_recipe(recipe):
"""Devuelve una lista de errores de schema (vacia si la receta es valida)."""
errors = []
if not isinstance(recipe, dict):
return [f"recipe debe ser dict, no {type(recipe).__name__}"]
for key in _REQUIRED:
val = recipe.get(key)
if not isinstance(val, str) or not val:
errors.append(f"campo requerido ausente o vacio: {key!r}")
slug = recipe.get("slug", "")
if isinstance(slug, str) and slug and ("/" in slug or "\\" in slug or slug.startswith(".")):
errors.append(f"slug invalido (no puede contener separadores de ruta ni empezar por '.'): {slug!r}")
if "prompt_scaffold" in recipe and not isinstance(recipe["prompt_scaffold"], dict):
errors.append("prompt_scaffold debe ser dict")
if "params" in recipe and not isinstance(recipe["params"], dict):
errors.append("params debe ser dict")
if "loras" in recipe and not isinstance(recipe["loras"], list):
errors.append("loras debe ser lista")
if "blocks" in recipe and not isinstance(recipe["blocks"], list):
errors.append("blocks debe ser lista")
return errors
def _write_json(path, obj):
with open(path, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2, ensure_ascii=False)
def _rewrite_index(lib):
"""Regenera <library_dir>/INDEX.md listando todas las skills (best-effort)."""
rows = []
for slug in sorted(os.listdir(lib)):
recipe_path = os.path.join(lib, slug, "recipe.json")
if not os.path.isfile(recipe_path):
continue
try:
with open(recipe_path, encoding="utf-8") as fh:
r = json.load(fh)
except (OSError, json.JSONDecodeError):
continue
prov = r.get("provenance") or {}
nsfw = "yes" if prov.get("nsfw") else "no"
rows.append(
f"| {slug} | {r.get('title', '')} | {r.get('base_workflow', '')} | "
f"{r.get('version', '')} | {r.get('score_mean', 0)} | {nsfw} |"
)
lines = [
"# Skills library — ComfyUI",
"",
"Recetas versionadas del grupo `comfyui-skill`. Una fila por skill.",
"",
"| slug | title | base_workflow | version | score_mean | nsfw |",
"|---|---|---|---|---|---|",
*rows,
"",
]
with open(os.path.join(lib, "INDEX.md"), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
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.
Returns:
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
validacion o de escritura, ``ok=False`` y ``error`` describe la causa; nunca lanza.
"""
errors = _validate_recipe(recipe)
if errors:
return {"ok": False, "slug": recipe.get("slug", "") if isinstance(recipe, dict) else "",
"path": "", "recipe_path": "", "version_file": "", "n_versions": 0,
"error": "receta invalida: " + "; ".join(errors)}
slug = recipe["slug"]
lib = _lib_dir(library_dir)
skill_dir = os.path.join(lib, slug)
versions_dir = os.path.join(skill_dir, "versions")
try:
for sub in ("versions", "exports", "samples"):
os.makedirs(os.path.join(skill_dir, sub), exist_ok=True)
existing = [f for f in os.listdir(versions_dir)
if f.startswith("v") and f.endswith(".json")]
n = len(existing) + 1
version_file = os.path.join(versions_dir, f"v{n}.json")
recipe_path = os.path.join(skill_dir, "recipe.json")
_write_json(recipe_path, recipe)
_write_json(version_file, recipe)
# bitacora append-only (best-effort, no rompe el save si falla)
try:
entry = {"ts": int(time.time()), "snapshot": f"v{n}",
"recipe_version": recipe.get("version", ""), "action": "save"}
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass
try:
_rewrite_index(lib)
except OSError:
pass
return {"ok": True, "slug": slug, "path": skill_dir, "recipe_path": recipe_path,
"version_file": version_file, "n_versions": n, "error": ""}
except OSError as exc:
return {"ok": False, "slug": slug, "path": skill_dir, "recipe_path": "",
"version_file": "", "n_versions": 0, "error": f"fallo de escritura: {exc}"}
if __name__ == "__main__":
demo = {
"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
"title": "Demo", "base_workflow": "txt2img",
"checkpoint": "dreamshaper_8.safetensors",
"params": {"steps": 20, "cfg": 7.0},
"prompt_scaffold": {"positive": "{subject}", "negative": "", "trigger_words": []},
"provenance": {"source": "manual", "nsfw": False},
}
res = comfyui_save_skill(demo, library_dir="/tmp/skills_demo")
print(json.dumps(res, indent=2, ensure_ascii=False))
@@ -0,0 +1,102 @@
"""Tests de estructura para comfyui_build_skill_workflow (funcion pura).
Compila recetas de skill a workflows ComfyUI en API format y verifica que:
- el golden (txt2img + 1 LoRA + facedetailer) produce un dict bien formado con los
class_types esperados y el subject sustituido,
- el edge (sin loras ni blocks) produce el workflow base minimo,
- los params (seed/steps/cfg) se reflejan,
- los error paths lanzan SkillWorkflowError (base desconocido / base que pide imagen).
Offline: no toca GPU ni server.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import pytest # noqa: E402
from ml.comfyui_build_skill_workflow import build_skill_workflow, SkillWorkflowError # noqa: E402
from _comfyui_wf_assert import assert_api_format, class_types, node_by_ct # noqa: E402
def _recipe_base(**over):
r = {
"schema_version": 1,
"slug": "portrait_cinematic_sdxl",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "juggernaut_xl_v11.safetensors",
"loras": [],
"params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m",
"scheduler": "karras", "width": 832, "height": 1216},
"prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres", "trigger_words": []},
"blocks": [],
"provenance": {"source": "manual", "nsfw": False},
}
r.update(over)
return r
def test_golden_txt2img_lora_facedetailer():
recipe = _recipe_base(
loras=[{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}],
blocks=[{"type": "facedetailer", "params": {"denoise": 0.45}}],
)
wf = build_skill_workflow(recipe, "a woman with red hair", seed=42)
assert_api_format(wf)
cts = class_types(wf)
assert "CheckpointLoaderSimple" in cts
assert "KSampler" in cts
assert "LoraLoader" in cts # el LoRA se inyecto
assert "FaceDetailer" in cts # el bloque facedetailer se aplico
assert "SaveImage" in cts
# seed propagada al KSampler base.
assert node_by_ct(wf, "KSampler")["inputs"]["seed"] == 42
# subject sustituido en algun CLIPTextEncode positivo.
textos = [n["inputs"]["text"] for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
assert any("a woman with red hair" in t for t in textos)
# el LoRA respeta la fuerza de la receta.
lora = node_by_ct(wf, "LoraLoader")["inputs"]
assert lora["lora_name"] == "add_detail.safetensors"
assert lora["strength_model"] == 0.6
def test_edge_sin_loras_ni_blocks_da_workflow_base_minimo():
wf = build_skill_workflow(_recipe_base(), "a red apple", seed=7)
assert_api_format(wf)
assert class_types(wf) == {
"CheckpointLoaderSimple", "CLIPTextEncode", "EmptyLatentImage",
"KSampler", "VAEDecode", "SaveImage",
}
assert "LoraLoader" not in class_types(wf)
assert "FaceDetailer" not in class_types(wf)
def test_params_y_trigger_words_se_reflejan():
recipe = _recipe_base()
recipe["prompt_scaffold"]["trigger_words"] = ["masterpiece", "8k"]
wf = build_skill_workflow(recipe, "a cat", seed=3)
ks = node_by_ct(wf, "KSampler")["inputs"]
assert ks["steps"] == 30 and ks["cfg"] == 5.5 and ks["seed"] == 3
lat = node_by_ct(wf, "EmptyLatentImage")["inputs"]
assert lat["width"] == 832 and lat["height"] == 1216
pos = [n["inputs"]["text"] for n in wf.values() if n["class_type"] == "CLIPTextEncode"]
assert any(t.startswith("masterpiece, 8k") for t in pos)
def test_error_base_workflow_desconocido():
with pytest.raises(SkillWorkflowError):
build_skill_workflow(_recipe_base(base_workflow="diffusion_xyz"), "x")
def test_error_base_que_requiere_imagen():
with pytest.raises(SkillWorkflowError):
build_skill_workflow(_recipe_base(base_workflow="img2img"), "x")
def test_error_recipe_no_dict():
with pytest.raises(SkillWorkflowError):
build_skill_workflow(["no", "dict"], "x")
@@ -0,0 +1,71 @@
"""Tests de estructura y pureza para comfyui_inject_hires_fix (funcion pura)."""
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_txt2img_workflow import comfyui_build_txt2img_workflow
from ml.comfyui_inject_hires_fix import comfyui_inject_hires_fix
from _comfyui_wf_assert import assert_api_format, class_types
def _node_id(wf, ct):
return next(nid for nid, n in wf.items() if n["class_type"] == ct)
def test_no_muta_la_entrada():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
base_antes = {k: dict(v) for k, v in base.items()}
_ = comfyui_inject_hires_fix(base)
# La copia profunda garantiza que el dict original queda intacto (pureza).
assert "UltimateSDUpscale" not in class_types(base)
assert "UpscaleModelLoader" not in class_types(base)
assert set(base) == set(base_antes)
def test_inserta_ultimatesdupscale_y_loader():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_hires_fix(base)
assert_api_format(inj)
assert "UltimateSDUpscale" in class_types(inj)
assert "UpscaleModelLoader" in class_types(inj)
def test_repunta_el_saveimage_al_upscale():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_hires_fix(base)
upscale_id = _node_id(inj, "UltimateSDUpscale")
save = next(n for n in inj.values() if n["class_type"] == "SaveImage")
# El SaveImage debe tomar la imagen del UltimateSDUpscale, no ya del VAEDecode.
assert save["inputs"]["images"][0] == upscale_id
def test_params_reflejados():
base = comfyui_build_txt2img_workflow("ck.safetensors", "POS", "NEG")
inj = comfyui_inject_hires_fix(
base, upscale_by=2.5, denoise=0.33, seed=123, upscale_model="x4.pth"
)
upscale_id = _node_id(inj, "UltimateSDUpscale")
loader_id = _node_id(inj, "UpscaleModelLoader")
up_in = inj[upscale_id]["inputs"]
assert up_in["upscale_by"] == 2.5
assert up_in["denoise"] == 0.33
assert up_in["seed"] == 123
assert inj[loader_id]["inputs"]["model_name"] == "x4.pth"
# Defaults fijos copiados del builder hermano.
assert up_in["mode_type"] == "Linear"
assert up_in["force_uniform_tiles"] is True
assert up_in["tiled_decode"] is False
def test_lanza_valueerror_sin_vaedecode():
# Workflow sin VAEDecode: la funcion no puede localizar la fuente de imagen.
bad = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
}
with pytest.raises(ValueError):
comfyui_inject_hires_fix(bad)