cda36408d0
Renombra los 13 checkpoints/diffusion models de ComfyUI prefijando la
categoría al inicio del nombre, para que en el dropdown de carga el usuario
distinga de inmediato imagen/vídeo/3D y no cargue un modelo en el nodo
equivocado. Misma operación que se hizo con los LoRAs (report 0197) pero
sobre los modelos.
Clasificación:
- IMG_: dreamshaper_8, juggernaut_xl_v11, v1-5-pruned-emaonly-fp16,
flux1-dev-fp8-e4m3fn, flux1-schnell-fp8-e4m3fn
- VIDEO_: svd, ltx-video-2b-v0.9.5, wan2.1_t2v_1.3B_fp16
- 3D_: stable_zero123, sv3d_p, hunyuan3d-dit-v2-mini, hunyuan3d-dit-v2-mv,
hy3dgen/hunyuan3d-dit-v2-0-fp16 (mantiene subcarpeta)
A diferencia de los LoRAs aquí solo se PREFIJA la categoría conservando el
nombre completo (versión/arquitectura). Archivos físicos renombrados en
~/ComfyUI/models/checkpoints, /mnt/2tb/comfyui_models/{checkpoints,
diffusion_models} y la subcarpeta hy3dgen/. Mapa de reversión en
~/ComfyUI/models/checkpoints/_ckpt_rename_map.json.
Actualiza todas las refs (ckpt_name/unet_name + defaults + prosa) en los
builders gamedev/vídeo/3D, style presets, pipelines, tests y los workflows
de ComfyUI. Arregla de paso el default roto de comfyui_text_to_3d_oneshot
(apuntaba a v1-5-pruned-emaonly.safetensors inexistente; ahora al real
IMG_v1-5-pruned-emaonly-fp16.safetensors).
No tocados (justificado): repo-paths de HuggingFace en comfyui_install_3d_model
(<repo>/model.fp16.safetensors son rutas de descarga, no nombres de dropdown)
y el mock de stable-diffusion.cpp en test_genconfig_to_sdcpp_args.
Verificado: dropdowns CheckpointLoaderSimple + UNETLoader listan los nombres
con prefijo; 1 generación real con IMG_juggernaut_xl_v11 (node_errors vacío,
pixelart_00003_.png); 327 tests comfyui verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.5 KiB
Python
161 lines
6.5 KiB
Python
"""comfyui_save_skill — persiste una receta de *skill* ComfyUI en la libreria de disco.
|
|
|
|
Una **skill** es una receta versionada del grupo `comfyui-skill`. Esta funcion valida el schema
|
|
minimo de la receta y la escribe en la libreria:
|
|
|
|
<library_dir>/<slug>/
|
|
recipe.json # version actual (la receta tal cual, sin mutar)
|
|
versions/vN.json # snapshot inmutable de cada save (N incremental)
|
|
exports/ # plantillas de workflow exportadas (vacio al crear)
|
|
samples/ # imagenes de muestra (vacio al crear)
|
|
growth_log.jsonl # bitacora append-only de cada save
|
|
<library_dir>/INDEX.md # indice regenerado con todas las skills
|
|
|
|
`library_dir` por defecto `~/ComfyUI/skills_library`. La funcion NO muta la receta: lo que se
|
|
escribe en `recipe.json` es identico al dict de entrada (garantiza round-trip con
|
|
`comfyui_load_skill`).
|
|
|
|
Impura: escribe archivos en disco.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
DEFAULT_LIBRARY = "~/ComfyUI/skills_library"
|
|
|
|
_REQUIRED = ("slug", "base_workflow", "version")
|
|
|
|
|
|
def _lib_dir(library_dir):
|
|
return os.path.expanduser(library_dir or DEFAULT_LIBRARY)
|
|
|
|
|
|
def _validate_recipe(recipe):
|
|
"""Devuelve una lista de errores de schema (vacia si la receta es valida)."""
|
|
errors = []
|
|
if not isinstance(recipe, dict):
|
|
return [f"recipe debe ser dict, no {type(recipe).__name__}"]
|
|
for key in _REQUIRED:
|
|
val = recipe.get(key)
|
|
if not isinstance(val, str) or not val:
|
|
errors.append(f"campo requerido ausente o vacio: {key!r}")
|
|
slug = recipe.get("slug", "")
|
|
if isinstance(slug, str) and slug and ("/" in slug or "\\" in slug or slug.startswith(".")):
|
|
errors.append(f"slug invalido (no puede contener separadores de ruta ni empezar por '.'): {slug!r}")
|
|
if "prompt_scaffold" in recipe and not isinstance(recipe["prompt_scaffold"], dict):
|
|
errors.append("prompt_scaffold debe ser dict")
|
|
if "params" in recipe and not isinstance(recipe["params"], dict):
|
|
errors.append("params debe ser dict")
|
|
if "loras" in recipe and not isinstance(recipe["loras"], list):
|
|
errors.append("loras debe ser lista")
|
|
if "blocks" in recipe and not isinstance(recipe["blocks"], list):
|
|
errors.append("blocks debe ser lista")
|
|
return errors
|
|
|
|
|
|
def _write_json(path, obj):
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(obj, fh, indent=2, ensure_ascii=False)
|
|
|
|
|
|
def _rewrite_index(lib):
|
|
"""Regenera <library_dir>/INDEX.md listando todas las skills (best-effort)."""
|
|
rows = []
|
|
for slug in sorted(os.listdir(lib)):
|
|
recipe_path = os.path.join(lib, slug, "recipe.json")
|
|
if not os.path.isfile(recipe_path):
|
|
continue
|
|
try:
|
|
with open(recipe_path, encoding="utf-8") as fh:
|
|
r = json.load(fh)
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
prov = r.get("provenance") or {}
|
|
nsfw = "yes" if prov.get("nsfw") else "no"
|
|
rows.append(
|
|
f"| {slug} | {r.get('title', '')} | {r.get('base_workflow', '')} | "
|
|
f"{r.get('version', '')} | {r.get('score_mean', 0)} | {nsfw} |"
|
|
)
|
|
lines = [
|
|
"# Skills library — ComfyUI",
|
|
"",
|
|
"Recetas versionadas del grupo `comfyui-skill`. Una fila por skill.",
|
|
"",
|
|
"| slug | title | base_workflow | version | score_mean | nsfw |",
|
|
"|---|---|---|---|---|---|",
|
|
*rows,
|
|
"",
|
|
]
|
|
with open(os.path.join(lib, "INDEX.md"), "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(lines))
|
|
|
|
|
|
def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict:
|
|
"""Valida y persiste una receta de skill en la libreria de disco.
|
|
|
|
Args:
|
|
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
|
|
`base_workflow` y `version` (strings no vacios). No se muta.
|
|
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
|
|
|
Returns:
|
|
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
|
|
validacion o de escritura, ``ok=False`` y ``error`` describe la causa; nunca lanza.
|
|
"""
|
|
errors = _validate_recipe(recipe)
|
|
if errors:
|
|
return {"ok": False, "slug": recipe.get("slug", "") if isinstance(recipe, dict) else "",
|
|
"path": "", "recipe_path": "", "version_file": "", "n_versions": 0,
|
|
"error": "receta invalida: " + "; ".join(errors)}
|
|
|
|
slug = recipe["slug"]
|
|
lib = _lib_dir(library_dir)
|
|
skill_dir = os.path.join(lib, slug)
|
|
versions_dir = os.path.join(skill_dir, "versions")
|
|
try:
|
|
for sub in ("versions", "exports", "samples"):
|
|
os.makedirs(os.path.join(skill_dir, sub), exist_ok=True)
|
|
|
|
existing = [f for f in os.listdir(versions_dir)
|
|
if f.startswith("v") and f.endswith(".json")]
|
|
n = len(existing) + 1
|
|
version_file = os.path.join(versions_dir, f"v{n}.json")
|
|
recipe_path = os.path.join(skill_dir, "recipe.json")
|
|
|
|
_write_json(recipe_path, recipe)
|
|
_write_json(version_file, recipe)
|
|
|
|
# bitacora append-only (best-effort, no rompe el save si falla)
|
|
try:
|
|
entry = {"ts": int(time.time()), "snapshot": f"v{n}",
|
|
"recipe_version": recipe.get("version", ""), "action": "save"}
|
|
with open(os.path.join(skill_dir, "growth_log.jsonl"), "a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
try:
|
|
_rewrite_index(lib)
|
|
except OSError:
|
|
pass
|
|
|
|
return {"ok": True, "slug": slug, "path": skill_dir, "recipe_path": recipe_path,
|
|
"version_file": version_file, "n_versions": n, "error": ""}
|
|
except OSError as exc:
|
|
return {"ok": False, "slug": slug, "path": skill_dir, "recipe_path": "",
|
|
"version_file": "", "n_versions": 0, "error": f"fallo de escritura: {exc}"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
demo = {
|
|
"schema_version": 1, "slug": "demo_skill", "version": "1.0.0",
|
|
"title": "Demo", "base_workflow": "txt2img",
|
|
"checkpoint": "IMG_dreamshaper_8.safetensors",
|
|
"params": {"steps": 20, "cfg": 7.0},
|
|
"prompt_scaffold": {"positive": "{subject}", "negative": "", "trigger_words": []},
|
|
"provenance": {"source": "manual", "nsfw": False},
|
|
}
|
|
res = comfyui_save_skill(demo, library_dir="/tmp/skills_demo")
|
|
print(json.dumps(res, indent=2, ensure_ascii=False))
|