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:
@@ -58,6 +58,7 @@ de texto). `blocks[].type` ∈ {`facedetailer`, `hires_fix`}.
|
|||||||
| ID | Firma corta | Qué hace | Purity |
|
| 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_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_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_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_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
|
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.
|
`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
|
## Fronteras
|
||||||
|
|
||||||
- **No genera ni descarga modelos**: una skill referencia checkpoints/LoRAs por nombre; deben
|
- **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"]))
|
||||||
Reference in New Issue
Block a user