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
@@ -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)