diff --git a/docs/capabilities/comfyui-skill.md b/docs/capabilities/comfyui-skill.md new file mode 100644 index 00000000..17b1a074 --- /dev/null +++ b/docs/capabilities/comfyui-skill.md @@ -0,0 +1,142 @@ +# ComfyUI Skill — Recetas versionadas de generación reutilizables + +Tag: `comfyui-skill`. Grupo para tratar una configuración de generación de ComfyUI como una +**skill**: una receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + +bloques de post-proceso) que se guarda una vez y se reproduce a un workflow concreto cambiando +solo el *subject*. Es la doctrina del issue 0087 aplicada a la generación de imágenes: el registry +crece **promoviendo configuraciones que funcionan a recetas reutilizables**, no reescribiendo el +grafo de nodos cada vez. + +Construye sobre el grupo [`comfyui`](comfyui.md) (los builders puros de workflow y el ciclo +submit/wait). Una skill no es un workflow: es la *receta* que compila a uno. + +Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`. + +## Qué es una skill + +Una receta vive en `~/ComfyUI/skills_library//` y la manipulan las funciones de este grupo: + +``` +~/ComfyUI/skills_library/ + INDEX.md # índice regenerado de todas las skills + / + recipe.json # la receta actual + versions/vN.json # snapshot inmutable de cada save (N incremental) + growth_log.jsonl # bitácora append-only de cada save + exports/ # plantillas de workflow exportadas + samples/ # imágenes de muestra +``` + +### Schema de `recipe.json` (canónico) + +```json +{ + "schema_version": 1, + "slug": "portrait_cinematic_sdxl", + "version": "1.0.0", + "title": "Retrato cinematográfico SDXL", + "base_workflow": "txt2img", + "checkpoint": "juggernaut_xl_v11.safetensors", + "loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}], + "params": {"steps": 30, "cfg": 5.5, "sampler_name": "dpmpp_2m", + "scheduler": "karras", "width": 832, "height": 1216, "denoise": 1.0}, + "prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus", + "negative": "blurry, lowres", "trigger_words": []}, + "blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}, + {"type": "hires_fix", "params": {"upscale_by": 1.5, "denoise": 0.4}}], + "score_mean": 0.0, "score_n": 0, + "provenance": {"source": "manual", "nsfw": false}, + "export_template_path": "exports/portrait_cinematic_sdxl.template.json" +} +``` + +`base_workflow` ∈ {`txt2img`, `flux`, `sdxl_refiner`} (las bases que se generan desde un *subject* +de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}. + +## Funciones del grupo + +| ID | Firma corta | Qué hace | Purity | +|---|---|---|---| +| [comfyui_build_skill_workflow_py_ml](../../python/functions/ml/comfyui_build_skill_workflow.md) | `build_skill_workflow(recipe, subject, *, seed=0) -> dict` | Compila una receta a un workflow en API format: despacha al builder base, sustituye `{subject}` + trigger_words, encadena LoRAs y aplica los blocks en orden. `SkillWorkflowError` si la base es desconocida o requiere imagen. | **pura** | +| [comfyui_inject_hires_fix_py_ml](../../python/functions/ml/comfyui_inject_hires_fix.md) | `comfyui_inject_hires_fix(workflow, *, upscale_by=1.5, denoise=0.4, steps=20, ...) -> dict` | Inyecta una 2ª pasada hires-fix (UpscaleModelLoader + UltimateSDUpscale) sobre un workflow ya construido, repuntando el SaveImage. Versión encadenable-sobre-dict del builder hermano. | **pura** | +| [comfyui_save_skill_py_ml](../../python/functions/ml/comfyui_save_skill.md) | `comfyui_save_skill(recipe, *, library_dir=None) -> dict` | Valida el schema mínimo y escribe `recipe.json` + snapshot `versions/vN.json` + growth_log + INDEX.md. No muta la receta (round-trip con load). | impura | +| [comfyui_load_skill_py_ml](../../python/functions/ml/comfyui_load_skill.md) | `comfyui_load_skill(slug, *, version=None, library_dir=None) -> dict` | Lee `recipe.json` (actual) o un snapshot `versions/vN.json`. Slug/versión inexistente → `{ok:False}` sin lanzar. | impura | +| [comfyui_list_skills_py_ml](../../python/functions/ml/comfyui_list_skills.md) | `comfyui_list_skills(*, library_dir=None, include_nsfw=False) -> dict` | Lista las skills con slug/title/base_workflow/version/score/nsfw/n_versions. Oculta NSFW por defecto. | impura | +| [ask_llm_vision_py_core](../../python/functions/core/ask_llm_vision.md) | `ask_llm_vision(prompt, image_path='', *, image_b64='', media_type='', model='claude-opus-4-8', ...) -> dict` | Pregunta multimodal (imagen + texto) al modelo via API directa de Anthropic (grupo `claude-direct`). Útil para **puntuar** el PNG de una skill y alimentar `score_mean`. | impura | + +`build_skill_workflow` compone los builders del grupo [`comfyui`](comfyui.md): +`comfyui_build_txt2img_workflow`, `comfyui_build_flux_workflow`, +`comfyui_build_sdxl_refiner_workflow`, `comfyui_inject_lora`, +`comfyui_build_facedetailer_workflow` y `comfyui_inject_hires_fix`. + +## Ejemplo canónico end-to-end (receta → workflow → PNG → score) + +Guardar una skill, cargarla, compilarla a un workflow para un sujeto, encolarla y puntuar el +resultado con visión. Requiere el server ComfyUI en `127.0.0.1:8188` y los modelos de la receta +instalados. + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_save_skill import comfyui_save_skill +from ml.comfyui_load_skill import comfyui_load_skill +from ml.comfyui_build_skill_workflow import build_skill_workflow +from ml.comfyui_submit_workflow import comfyui_submit_workflow +from ml.comfyui_wait_result import comfyui_wait_result +from ml.comfyui_fetch_output_image import comfyui_fetch_output_image +from core.ask_llm_vision import ask_llm_vision + +# 1. Definir y guardar la skill (una vez). +recipe = { + "schema_version": 1, "slug": "portrait_cinematic_sdxl", "version": "1.0.0", + "title": "Retrato cinematográfico SDXL", "base_workflow": "txt2img", + "checkpoint": "dreamshaper_8.safetensors", + "loras": [{"name": "add_detail.safetensors", "strength_model": 0.6, "strength_clip": 0.6}], + "params": {"steps": 28, "cfg": 6.0, "sampler_name": "dpmpp_2m", "scheduler": "karras"}, + "prompt_scaffold": {"positive": "cinematic portrait of {subject}, sharp focus", + "negative": "blurry, lowres", "trigger_words": []}, + "blocks": [{"type": "facedetailer", "params": {"denoise": 0.45}}], + "score_mean": 0.0, "score_n": 0, "provenance": {"source": "manual", "nsfw": False}, +} +comfyui_save_skill(recipe) # ~/ComfyUI/skills_library/portrait_cinematic_sdxl/ + +# 2. Cargar + compilar a un workflow para un sujeto concreto. +recipe = comfyui_load_skill("portrait_cinematic_sdxl")["recipe"] +wf = build_skill_workflow(recipe, "a woman with red hair", seed=42) + +# 3. Encolar y esperar el PNG (camino headless del grupo comfyui). +pid = comfyui_submit_workflow(wf)["prompt_id"] +outputs = comfyui_wait_result(pid)["outputs"] +img = comfyui_fetch_output_image(outputs[0]["filename"], dest_dir="/tmp")["path"] + +# 4. Puntuar el resultado con visión (alimenta el bucle de scoring de la skill). +verdict = ask_llm_vision( + "Puntúa de 0 a 10 el realismo de este retrato. Responde solo el número.", + image_path=img, model="claude-opus-4-8", +) +print(verdict["text"]) +``` + +El paso "guardar la receta" se hace una sola vez; a partir de ahí cada generación es +`load → build → submit`, cambiando solo el `subject` y la `seed`. + +## Fronteras + +- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben + estar ya instalados en ComfyUI (`comfyui_download_model`, otro flujo). `build_skill_workflow` es + puro y no valida contra el servidor — usa `comfyui_validate_workflow` antes de encolar si dudas. +- **`base_workflow` solo de texto**: `txt2img`, `flux`, `sdxl_refiner`. Las bases que parten de una + imagen (`img2img`, `inpaint`, `controlnet`) lanzan `SkillWorkflowError`; para esas, monta el + workflow con los builders del grupo `comfyui` directamente. +- **`blocks` soportados**: `facedetailer` y `hires_fix`. Otros post-procesos (IPAdapter, + multi-ControlNet) se añaden creando su función-inyector hermana y registrándola en el dispatcher + de `build_skill_workflow`. +- **El scoring (`score_mean`/`score_n`) no se calcula aquí**: `ask_llm_vision` da el juicio del + modelo sobre una imagen, pero actualizar la receta con el score acumulado es trabajo de otra + pieza (el bucle de scoring) que reescribe la receta y la re-guarda con `comfyui_save_skill`. +- **La librería es metadata local**: vive bajo `~/ComfyUI/skills_library` (no toca el venv ni los + modelos en disco). No tiene repo propio ni se indexa — es estado vivo, como un `operations.db`. +- **Las funciones impuras del grupo** (save/load/list, ask_llm_vision) no llevan unit tests por + diseño (I/O de disco / red); `build_skill_workflow` e `inject_hires_fix` son puras y sí tienen + tests de estructura offline (`python/functions/ml/tests/test_comfyui_build_skill_workflow.py`, + `test_comfyui_inject_hires_fix.py`). diff --git a/python/functions/core/ask_llm_vision.md b/python/functions/core/ask_llm_vision.md new file mode 100644 index 00000000..cd3cc8e6 --- /dev/null +++ b/python/functions/core/ask_llm_vision.md @@ -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`. diff --git a/python/functions/core/ask_llm_vision.py b/python/functions/core/ask_llm_vision.py new file mode 100644 index 00000000..d4fc634b --- /dev/null +++ b/python/functions/core/ask_llm_vision.py @@ -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:])) diff --git a/python/functions/ml/comfyui_build_skill_workflow.md b/python/functions/ml/comfyui_build_skill_workflow.md new file mode 100644 index 00000000..26dc4c95 --- /dev/null +++ b/python/functions/ml/comfyui_build_skill_workflow.md @@ -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. diff --git a/python/functions/ml/comfyui_build_skill_workflow.py b/python/functions/ml/comfyui_build_skill_workflow.py new file mode 100644 index 00000000..485e1f20 --- /dev/null +++ b/python/functions/ml/comfyui_build_skill_workflow.py @@ -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)) diff --git a/python/functions/ml/comfyui_inject_hires_fix.md b/python/functions/ml/comfyui_inject_hires_fix.md new file mode 100644 index 00000000..231eebb1 --- /dev/null +++ b/python/functions/ml/comfyui_inject_hires_fix.md @@ -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`. diff --git a/python/functions/ml/comfyui_inject_hires_fix.py b/python/functions/ml/comfyui_inject_hires_fix.py new file mode 100644 index 00000000..93350fc7 --- /dev/null +++ b/python/functions/ml/comfyui_inject_hires_fix.py @@ -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)) diff --git a/python/functions/ml/comfyui_list_skills.md b/python/functions/ml/comfyui_list_skills.md new file mode 100644 index 00000000..7b0177b9 --- /dev/null +++ b/python/functions/ml/comfyui_list_skills.md @@ -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: ...}`. diff --git a/python/functions/ml/comfyui_list_skills.py b/python/functions/ml/comfyui_list_skills.py new file mode 100644 index 00000000..f04f3169 --- /dev/null +++ b/python/functions/ml/comfyui_list_skills.py @@ -0,0 +1,86 @@ +"""comfyui_list_skills — lista las *skills* ComfyUI guardadas en la libreria de disco. + +Escanea `//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)) diff --git a/python/functions/ml/comfyui_load_skill.md b/python/functions/ml/comfyui_load_skill.md new file mode 100644 index 00000000..e93dae0b --- /dev/null +++ b/python/functions/ml/comfyui_load_skill.md @@ -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. diff --git a/python/functions/ml/comfyui_load_skill.py b/python/functions/ml/comfyui_load_skill.py new file mode 100644 index 00000000..f9ea0233 --- /dev/null +++ b/python/functions/ml/comfyui_load_skill.py @@ -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)) diff --git a/python/functions/ml/comfyui_save_skill.md b/python/functions/ml/comfyui_save_skill.md new file mode 100644 index 00000000..eb1178e9 --- /dev/null +++ b/python/functions/ml/comfyui_save_skill.md @@ -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 //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. diff --git a/python/functions/ml/comfyui_save_skill.py b/python/functions/ml/comfyui_save_skill.py new file mode 100644 index 00000000..ac00319e --- /dev/null +++ b/python/functions/ml/comfyui_save_skill.py @@ -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: + + // + 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 + /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 /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)) diff --git a/python/functions/ml/tests/test_comfyui_build_skill_workflow.py b/python/functions/ml/tests/test_comfyui_build_skill_workflow.py new file mode 100644 index 00000000..61e22774 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_skill_workflow.py @@ -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") diff --git a/python/functions/ml/tests/test_comfyui_inject_hires_fix.py b/python/functions/ml/tests/test_comfyui_inject_hires_fix.py new file mode 100644 index 00000000..6b2fd385 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_inject_hires_fix.py @@ -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)