"""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: // 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 /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 /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": "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))