feat(ml): comfyui_export_skill_template — skills (recetas) como grafos cargables en el navegador

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/<slug>.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/<slug>.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) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 16:58:11 +02:00
parent 46954d8584
commit 04ecf9f394
4 changed files with 428 additions and 0 deletions
+37
View File
@@ -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/<slug>.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/<slug>.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/<slug>.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/<slug>.json` + copia en `exports/<slug>.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/<base>.png` + `samples/<base>.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
@@ -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/<slug>.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/<slug>.json (aparece en el menu Workflows del navegador y se abre como grafo visual) + copia en exports/<slug>.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 <library_dir>/<slug>/ 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/<slug>.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.
@@ -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 `<skill_dir>/exports/<slug>.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/<slug>.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
`<skill_dir>/exports/<slug>.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 `<library_dir>/<slug>/` 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/<slug>.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))
@@ -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/<slug>.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"]))