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