From 04ecf9f394b017321d4c615c29c7ad3aa1611b09 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 24 Jun 2026 16:58:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ml):=20comfyui=5Fexport=5Fskill=5Ftemplate?= =?UTF-8?q?=20=E2=80=94=20skills=20(recetas)=20como=20grafos=20cargables?= =?UTF-8?q?=20en=20el=20navegador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra el gap receta->grafo del grupo comfyui-skill. La función impura comfyui_export_skill_template compila una skill a template API format (exports/.template.json) y, con ui_graph=True, genera el UI graph posicionado vía CDP (load_workflow_ui + export_workflow_ui) en la carpeta nativa de la UI (~/ComfyUI/user/default/workflows/.json), de modo que la skill aparece en el menú Workflows del navegador y se abre como grafo visual. Sin navegador, deja el template API y reporta el fallback (no falla). - 4 tests offline (golden + edge + 2 error paths). - Página madre comfyui-skill.md: fila en la tabla del grupo + sección "Skills como grafos en el navegador". Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/capabilities/comfyui-skill.md | 37 ++++ .../ml/comfyui_export_skill_template.md | 91 +++++++++ .../ml/comfyui_export_skill_template.py | 187 ++++++++++++++++++ .../test_comfyui_export_skill_template.py | 113 +++++++++++ 4 files changed, 428 insertions(+) create mode 100644 python/functions/ml/comfyui_export_skill_template.md create mode 100644 python/functions/ml/comfyui_export_skill_template.py create mode 100644 python/functions/ml/tests/test_comfyui_export_skill_template.py diff --git a/docs/capabilities/comfyui-skill.md b/docs/capabilities/comfyui-skill.md index 3055a515..2ee3a2eb 100644 --- a/docs/capabilities/comfyui-skill.md +++ b/docs/capabilities/comfyui-skill.md @@ -58,6 +58,7 @@ de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}. | 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_export_skill_template_py_ml](../../python/functions/ml/comfyui_export_skill_template.md) | `export_skill_template(slug, *, ui_graph=False, port=9222, ...) -> dict` | Exporta una skill a artefactos cargables como GRAFO: template API en `exports/.template.json` y, con `ui_graph=True`, el UI graph posicionado (vía `load_workflow_ui`+`export_workflow_ui` por CDP) en la carpeta nativa `~/ComfyUI/user/default/workflows/.json` (menú Workflows del navegador). Sin navegador, deja el template API y reporta el fallback. | impura | | [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 | @@ -164,6 +165,42 @@ Pasos concretos: Así `versions/` y `growth_log` reflejan **versiones de receta con mejora demostrada**, mientras `score_mean` es la telemetría de calidad media de la versión vigente. +## Skills como grafos en el navegador + +Una skill no vive solo como receta JSON: se exporta a un **grafo de ComfyUI cargable como tal en el +navegador**. `comfyui_export_skill_template` cierra ese hueco (receta → grafo): + +```python +from ml.comfyui_export_skill_template import export_skill_template + +# Headless (sin navegador): congela el template API junto a la skill. +export_skill_template("portrait_cinematic_sd15") +# -> exports/portrait_cinematic_sd15.template.json (API format, node-template reproducible) + +# Con navegador (pestaña ComfyUI abierta en CDP 9222): además el grafo visual posicionado. +out = export_skill_template("portrait_cinematic_sd15", ui_graph=True, port=9222) +# -> ~/ComfyUI/user/default/workflows/portrait_cinematic_sd15.json (aparece en el menú Workflows) +``` + +Dos formatos, dos usos: + +- **API format** (`exports/.template.json`) — el dict `{node_id:{class_type,inputs}}`. Se + carga con `comfyui_load_workflow_ui` (`app.loadApiJson`, litegraph lo auto-posiciona) o va directo + a `comfyui_submit_workflow`. Es el node-template versionable de la skill. +- **UI graph** (`~/ComfyUI/user/default/workflows/.json` + copia en `exports/.ui.json`) + — `nodes`/`links`/`pos` (`app.graph.serialize()`). La carpeta nativa de la UI **solo** acepta este + formato; por eso solo se escribe con `ui_graph=True` (se genera vía CDP cargando el API en la UI y + serializando el grafo posicionado). Es el que se abre como grafo visual desde el menú Workflows. + +**Fotos ↔ grafo.** Cada PNG de ComfyUI lleva su workflow embebido (chunk `prompt`, API format). +`comfyui_import_workflow_png` lo recupera, de modo que toda muestra de una skill queda asociada a su +grafo reproducible 1:1 (ver `INDEX.md` de la librería: `samples/.png` + `samples/.graph.json`). + +**No destructivo en el navegador**: `ui_graph=True` reemplaza el grafo in-memory de la pestaña. Si +hay trabajo sin guardar (título con `*`), respalda antes con +`comfyui_export_workflow_ui(api_format=True, save_path=...)` y restáuralo después con +`comfyui_load_workflow_ui`. + ## Fronteras - **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben diff --git a/python/functions/ml/comfyui_export_skill_template.md b/python/functions/ml/comfyui_export_skill_template.md new file mode 100644 index 00000000..fb8c1b25 --- /dev/null +++ b/python/functions/ml/comfyui_export_skill_template.md @@ -0,0 +1,91 @@ +--- +name: comfyui_export_skill_template +kind: function +lang: py +domain: ml +version: "1.0.0" +purity: impure +signature: "def export_skill_template(slug: str, *, ui_graph: bool = False, port: int = 9222, library_dir: str = '~/ComfyUI/skills_library', ui_workflows_dir: str = '~/ComfyUI/user/default/workflows', subject_placeholder: str = '{subject}', seed: int = 0, server_url_substr: str = '8188', timeout_s: float = 20.0) -> dict" +description: "Exporta una skill (receta comfyui-skill) a artefactos cargables como GRAFO en ComfyUI. Compila la receta con comfyui_build_skill_workflow (subject placeholder) y escribe el template en API format en exports/.template.json. Con ui_graph=True carga ese workflow en la UI del navegador via CDP (comfyui_load_workflow_ui -> litegraph auto-posiciona), extrae el UI graph serializado (comfyui_export_workflow_ui api_format=False) y lo guarda en la carpeta nativa ~/ComfyUI/user/default/workflows/.json (aparece en el menu Workflows del navegador y se abre como grafo visual) + copia en exports/.ui.json. Si el navegador/CDP no esta disponible no falla: deja el template API y devuelve error explicando el fallback. Skill sin recipe.json -> ok False." +tags: [comfyui, comfyui-skill, ml, workflow, template, browser, cdp, stable-diffusion] +uses_functions: + - comfyui_build_skill_workflow_py_ml + - comfyui_load_workflow_ui_py_browser + - comfyui_export_workflow_ui_py_browser +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: slug + desc: "Slug de la skill: carpeta // que contiene recipe.json." + - name: ui_graph + desc: "Si True, ademas del template API carga el workflow en la UI de ComfyUI via CDP, extrae el UI graph posicionado (app.graph.serialize) y lo guarda en la carpeta nativa de la UI (cargable desde el menu Workflows del navegador). Requiere una pestana de ComfyUI abierta. Default False." + - name: port + desc: "Puerto de remote debugging del Chrome diario (solo si ui_graph=True). Default 9222." + - name: library_dir + desc: "Raiz de la libreria de skills. Default ~/ComfyUI/skills_library." + - name: ui_workflows_dir + desc: "Carpeta nativa de workflows de la UI de ComfyUI. Default ~/ComfyUI/user/default/workflows." + - name: subject_placeholder + desc: "Texto que sustituye {subject} en el scaffold del prompt del template. Default '{subject}' (literal, editable por el usuario)." + - name: seed + desc: "Semilla fijada en el template. Default 0." + - name: server_url_substr + desc: "Substring de la URL de la pestana de ComfyUI para identificarla entre las abiertas. Default '8188'." + - name: timeout_s + desc: "Timeout de la conexion CDP en segundos. Default 20.0." +output: "dict {ok, template_path, ui_workflow_path, error}. Campo extra ui_export_path (copia del UI graph en exports/). ok True si el template API se escribio; con ui_graph=True y CDP no disponible ok sigue True (template escrito), ui_workflow_path None y error explica el fallback. Skill sin recipe.json -> ok False." +tested: true +tests: ["golden: skill txt2img+facedetailer (sin ui_graph) -> ok True, template.json escrito con API format valido (class_type/inputs) y subject placeholder presente", "edge: respeta export_template_path de la receta", "error: slug inexistente (sin recipe.json) -> ok False", "error: receta con base_workflow desconocido -> ok False"] +test_file_path: "python/functions/ml/tests/test_comfyui_export_skill_template.py" +file_path: "python/functions/ml/comfyui_export_skill_template.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions")) +from ml.comfyui_export_skill_template import export_skill_template + +# Solo template API (sin navegador): rápido, headless. +out = export_skill_template("portrait_cinematic_sd15") +print(out["ok"], out["template_path"]) +# -> True ~/ComfyUI/skills_library/portrait_cinematic_sd15/exports/portrait_cinematic_sd15.template.json + +# Template + GRAFO cargable en el navegador (requiere pestaña ComfyUI abierta en CDP 9222): +out = export_skill_template("portrait_cinematic_sd15", ui_graph=True, port=9222) +print(out["ui_workflow_path"]) +# -> ~/ComfyUI/user/default/workflows/portrait_cinematic_sd15.json (aparece en el menú Workflows) +``` + +Lanzable directo: `./fn run comfyui_export_skill_template portrait_cinematic_sd15 --ui-graph` + +## Cuando usarla + +- Cuando quieras **congelar una skill como grafo reproducible**: tras afinar una receta del grupo + `comfyui-skill`, exportarla a un node-template (API format) versionable junto a la skill. +- Cuando quieras **ver/editar la skill como GRAFO visual** en el navegador: con `ui_graph=True` + el workflow aparece en el menú Workflows de ComfyUI y se abre como grafo posicionado, listo + para retocar a mano antes de generar. +- Es el paso "receta → grafo cargable" del flujo del grupo `comfyui-skill`: + `load_skill` → `build_skill_workflow` → **`export_skill_template`** → abrir en la UI. + +## Gotchas + +- **`ui_graph=True` necesita el navegador**: una pestaña de ComfyUI abierta en el Chrome diario + con CDP en el puerto `port` (9222). Sin ella, la función NO falla — escribe el template API y + devuelve `error` con el fallback (arrastrar el `.json` a la UI o usar `comfyui_load_workflow_ui`). +- **La carpeta nativa de la UI solo acepta UI graph format** (`nodes`/`links`/`pos`), no API + format. Por eso la copia a `~/ComfyUI/user/default/workflows/.json` solo ocurre con + `ui_graph=True` (el UI graph se genera vía CDP). El template API de `exports/` se carga con + `app.loadApiJson` / `comfyui_load_workflow_ui`, no desde el menú Workflows. +- **Reemplaza el grafo in-memory de la pestaña**: `ui_graph=True` carga el workflow sobre el grafo + actual de la UI. Si hay trabajo sin guardar en esa pestaña (título con `*`), haz backup antes + con `comfyui_export_workflow_ui(api_format=True, save_path=...)`. +- **No valida modelos**: hereda de `comfyui_build_skill_workflow` — no comprueba que el + checkpoint/LoRA existan en el servidor; eso lo valida ComfyUI al enviar el grafo. +- **Bases solo de texto**: `base_workflow` ∈ {txt2img, flux, sdxl_refiner}. Las bases con imagen + de entrada lanzan error en el build → `ok` False. diff --git a/python/functions/ml/comfyui_export_skill_template.py b/python/functions/ml/comfyui_export_skill_template.py new file mode 100644 index 00000000..b26a0a22 --- /dev/null +++ b/python/functions/ml/comfyui_export_skill_template.py @@ -0,0 +1,187 @@ +"""comfyui_export_skill_template — exporta una *skill* (receta) a un node-template/workflow ComfyUI. + +Una **skill** es una receta versionada (`recipe.json` del grupo `comfyui-skill`). Esta función +impura la "congela" en dos artefactos cargables: + +1. **Template API format** en `/exports/.template.json` — el dict + `{node_id: {class_type, inputs}}` que produce `comfyui_build_skill_workflow` con un + `subject` placeholder. Es el node-template reproducible de la skill (apto para + `comfyui_submit_workflow` o `app.loadApiJson`). +2. **UI graph posicionado** (solo con `ui_graph=True`) en la carpeta nativa de la UI + `~/ComfyUI/user/default/workflows/.json` — el grafo con `nodes`/`links`/`pos` + (`app.graph.serialize()`), que aparece en el menú **Workflows** del navegador y se abre como + GRAFO visual. Se genera cargando el API format en la UI (litegraph lo auto-posiciona) y + extrayendo el grafo serializado, vía CDP. También se guarda una copia en + `/exports/.ui.json`. + +El UI graph requiere una pestaña de ComfyUI abierta en el navegador (CDP). Si el navegador no +está disponible, la función NO falla: deja escrito el template API format y devuelve un `error` +explicando cómo cargarlo a mano. La carpeta nativa de la UI solo acepta el UI graph format +(`nodes`/`links`), no el API format — por eso la copia a esa carpeta solo ocurre con `ui_graph=True`. + +Función impura: hace red (CDP WebSocket, solo si `ui_graph=True`) y escribe en disco. +""" +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +class SkillTemplateError(RuntimeError): + """Error tipado al exportar una skill a template (skill inexistente, receta inválida).""" + + +def _load_recipe(slug: str, library_dir: str): + """Lee `recipe.json` de la skill. Devuelve (recipe|None, skill_dir, recipe_path).""" + skill_dir = os.path.join(os.path.expanduser(library_dir), slug) + recipe_path = os.path.join(skill_dir, "recipe.json") + if not os.path.isfile(recipe_path): + return None, skill_dir, recipe_path + with open(recipe_path, encoding="utf-8") as fh: + return json.load(fh), skill_dir, recipe_path + + +def export_skill_template( + slug: str, + *, + ui_graph: bool = False, + port: int = 9222, + library_dir: str = "~/ComfyUI/skills_library", + ui_workflows_dir: str = "~/ComfyUI/user/default/workflows", + subject_placeholder: str = "{subject}", + seed: int = 0, + server_url_substr: str = "8188", + timeout_s: float = 20.0, +) -> dict: + """Exporta una skill a un node-template (API format) y, opcionalmente, a un grafo de UI. + + Args: + slug: slug de la skill (carpeta `//` con `recipe.json`). + ui_graph: si True, además del template API carga el workflow en la UI de ComfyUI vía + CDP, extrae el UI graph posicionado (`app.graph.serialize()`) y lo guarda en la + carpeta nativa de la UI (cargable desde el menú Workflows del navegador). Default False. + port: puerto de remote debugging del Chrome diario (solo si `ui_graph=True`). Default 9222. + library_dir: raíz de la librería de skills. Default `~/ComfyUI/skills_library`. + ui_workflows_dir: carpeta nativa de workflows de la UI de ComfyUI. Default + `~/ComfyUI/user/default/workflows`. + subject_placeholder: texto que sustituye `{subject}` en el scaffold del prompt del + template. Default `"{subject}"` (se mantiene literal para que el usuario lo edite). + seed: semilla que se fija en el template. Default 0. + server_url_substr: substring de la URL de la pestaña de ComfyUI (default "8188"). + timeout_s: timeout de la conexión CDP en segundos. + + Returns: + dict {ok, template_path, ui_workflow_path, error}. Campos extra: `ui_export_path` (copia + del UI graph en `exports/`). `ok` True si el template API se escribió. Con `ui_graph=True` + y CDP no disponible, `ok` sigue True (template escrito), `ui_workflow_path` queda None y + `error` explica el fallback. Skill sin `recipe.json` → `ok` False. + """ + from ml.comfyui_build_skill_workflow import SkillWorkflowError, build_skill_workflow + + recipe, skill_dir, recipe_path = _load_recipe(slug, library_dir) + if recipe is None: + return { + "ok": False, + "template_path": None, + "ui_workflow_path": None, + "error": f"skill sin recipe.json: {recipe_path}", + } + + # 1. receta -> API format (subject placeholder, seed fija). + try: + api_workflow = build_skill_workflow(recipe, subject_placeholder, seed=seed) + except SkillWorkflowError as exc: + return { + "ok": False, + "template_path": None, + "ui_workflow_path": None, + "error": f"build_skill_workflow: {exc}", + } + + # 2. escribe exports/.template.json (API format) — respeta export_template_path si existe. + export_rel = recipe.get("export_template_path") or os.path.join("exports", f"{slug}.template.json") + template_path = os.path.join(skill_dir, export_rel) + os.makedirs(os.path.dirname(template_path), exist_ok=True) + with open(template_path, "w", encoding="utf-8") as fh: + json.dump(api_workflow, fh, ensure_ascii=False, indent=2) + + result = { + "ok": True, + "template_path": template_path, + "ui_workflow_path": None, + "error": "", + } + + if not ui_graph: + return result + + # 3. ui_graph=True: carga el API en la UI (litegraph posiciona) y extrae el UI graph serializado. + from browser.comfyui_export_workflow_ui import comfyui_export_workflow_ui + from browser.comfyui_load_workflow_ui import comfyui_load_workflow_ui + + ui_path = os.path.join(os.path.expanduser(ui_workflows_dir), f"{slug}.json") + + loaded = comfyui_load_workflow_ui( + api_workflow, + port=port, + server_url_substr=server_url_substr, + filename=f"{slug}.json", + timeout_s=timeout_s, + ) + if not loaded.get("ok"): + result["error"] = ( + f"ui_graph no disponible (CDP/navegador): {loaded.get('error')}. " + f"Template API escrito en {template_path}; cárgalo a mano arrastrándolo a la UI " + f"de ComfyUI o con comfyui_load_workflow_ui." + ) + return result + + exported = comfyui_export_workflow_ui( + port=port, + server_url_substr=server_url_substr, + api_format=False, + save_path=ui_path, + timeout_s=timeout_s, + ) + if not exported.get("ok"): + result["error"] = ( + f"export_workflow_ui falló: {exported.get('error')}. Template API escrito en " + f"{template_path}." + ) + return result + + result["ui_workflow_path"] = exported.get("saved_to") or ui_path + + # Copia del UI graph posicionado junto al template API, en exports/. + ui_export_path = os.path.join( + skill_dir, os.path.dirname(export_rel) or "exports", f"{slug}.ui.json" + ) + os.makedirs(os.path.dirname(ui_export_path), exist_ok=True) + with open(ui_export_path, "w", encoding="utf-8") as fh: + json.dump(exported.get("workflow") or {}, fh, ensure_ascii=False, indent=2) + result["ui_export_path"] = ui_export_path + + return result + + +# Alias con el nombre completo del ID para descubrimiento por convención. +comfyui_export_skill_template = export_skill_template + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Exporta una skill ComfyUI a template/grafo.") + parser.add_argument("slug", nargs="?", default="portrait_cinematic_sd15") + parser.add_argument("--ui-graph", action="store_true", help="además genera el UI graph vía CDP") + parser.add_argument("--port", type=int, default=9222) + parser.add_argument("--library-dir", default="~/ComfyUI/skills_library") + args = parser.parse_args() + + out = export_skill_template( + args.slug, ui_graph=args.ui_graph, port=args.port, library_dir=args.library_dir + ) + print(json.dumps(out, ensure_ascii=False, indent=2)) diff --git a/python/functions/ml/tests/test_comfyui_export_skill_template.py b/python/functions/ml/tests/test_comfyui_export_skill_template.py new file mode 100644 index 00000000..fc972efd --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_export_skill_template.py @@ -0,0 +1,113 @@ +"""Tests de comfyui_export_skill_template (función impura, sin ui_graph -> offline). + +Verifica el camino headless (sin navegador), que es el que se puede testear sin GPU ni CDP: +- golden: una skill txt2img+facedetailer válida en un library_dir temporal -> ok True, escribe + exports/.template.json con API format válido (class_type/inputs) y el subject placeholder + presente en el prompt, +- edge: respeta export_template_path declarado en la receta, +- error: slug inexistente (sin recipe.json) -> ok False, +- error: receta con base_workflow desconocido -> ok False. + +El camino ui_graph=True (carga/extrae el grafo en el navegador vía CDP) se valida en vivo, no +aquí: depende de una pestaña de ComfyUI abierta y queda fuera del alcance de un test offline. +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import pytest # noqa: E402 + +from ml.comfyui_export_skill_template import export_skill_template # noqa: E402 +from _comfyui_wf_assert import assert_api_format, class_types # noqa: E402 + + +def _write_skill(library_dir, slug, recipe, *, export_template_path=None): + skill_dir = os.path.join(library_dir, slug) + os.makedirs(skill_dir, exist_ok=True) + if export_template_path is not None: + recipe = dict(recipe, export_template_path=export_template_path) + with open(os.path.join(skill_dir, "recipe.json"), "w", encoding="utf-8") as fh: + json.dump(recipe, fh) + return skill_dir + + +def _recipe_txt2img(**over): + r = { + "schema_version": 1, + "slug": "demo_skill", + "version": "1.0.0", + "base_workflow": "txt2img", + "checkpoint": "dreamshaper_8.safetensors", + "loras": [], + "params": {"steps": 28, "cfg": 6.0, "width": 512, "height": 768}, + "prompt_scaffold": { + "positive": "cinematic portrait of {subject}, sharp focus", + "negative": "blurry, lowres", + "trigger_words": [], + }, + "blocks": [{"type": "facedetailer", "params": {"denoise": 0.4}}], + } + r.update(over) + return r + + +def test_golden_headless_writes_api_template(tmp_path): + lib = str(tmp_path / "skills_library") + _write_skill(lib, "demo_skill", _recipe_txt2img()) + + out = export_skill_template("demo_skill", library_dir=lib) # ui_graph=False por defecto + + assert out["ok"] is True + assert out["ui_workflow_path"] is None # sin ui_graph no toca la carpeta nativa + path = out["template_path"] + assert os.path.isfile(path) + + workflow = json.load(open(path, encoding="utf-8")) + assert_api_format(workflow) # dict {node_id: {class_type, inputs}} + cts = class_types(workflow) + assert "CheckpointLoaderSimple" in cts + assert "FaceDetailer" in cts # el block facedetailer se aplicó + # el subject placeholder se mantiene literal en algún CLIPTextEncode + assert any("{subject}" in json.dumps(node.get("inputs", {})) for node in workflow.values()) + + +def test_edge_respects_export_template_path(tmp_path): + lib = str(tmp_path / "skills_library") + _write_skill(lib, "demo_skill", _recipe_txt2img(), + export_template_path="exports/custom_name.template.json") + + out = export_skill_template("demo_skill", library_dir=lib) + + assert out["ok"] is True + assert out["template_path"].endswith(os.path.join("exports", "custom_name.template.json")) + assert os.path.isfile(out["template_path"]) + + +def test_error_missing_skill(tmp_path): + lib = str(tmp_path / "skills_library") + os.makedirs(lib, exist_ok=True) + + out = export_skill_template("no_existe", library_dir=lib) + + assert out["ok"] is False + assert out["template_path"] is None + assert "recipe.json" in out["error"] + + +def test_error_unknown_base_workflow(tmp_path): + lib = str(tmp_path / "skills_library") + _write_skill(lib, "bad_skill", _recipe_txt2img(base_workflow="totally_unknown")) + + out = export_skill_template("bad_skill", library_dir=lib) + + assert out["ok"] is False + assert out["template_path"] is None + assert "build_skill_workflow" in out["error"] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))