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:
@@ -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`.
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user